6 Commits

Author SHA1 Message Date
9ecb292c26 ki integration 2026-08-15 13:43:52 +02:00
82d98bd8cf promts ueberarbeitung 2026-08-06 13:16:19 +02:00
a25256b739 Merge pull request 'desktop-ansicht' (#2) from desktop-ansicht into main
Reviewed-on: #2
2026-08-06 10:45:27 +00:00
7c63380b35 localer plan 2026-08-06 12:44:41 +02:00
4c47090f42 Feed wird Raster statt Kartenstapel, Swipen entfaellt
Rueckmeldung vom Kunden: der Screenshot war als Funktionsaenderung
gemeint, nicht nur als Optik. Statt eine Karte nach der anderen
durchzuschieben, liegen jetzt alle nebeneinander und werden einzeln
gemerkt, ausgeblendet oder geoeffnet.

Raus:
- Kartenstapel, Maus-Drag, Pfeiltasten, Exit-Animation
- Undo samt History - "gemerkt" ist ein Zustand, den ein zweiter Klick
  zuruecknimmt, keine Entscheidung in einer History
- die drei Aktions-Buttons, das Kuerzel-Panel, die feste Detailspalte

Rein:
- Masonry-Raster (columns 3-6, Standard 5) mit gestaffelten Hoehen
- Merken-Pille und ⋯ beim Hover auf der Kachel; ⋯ bietet "Nicht mehr
  zeigen" und "Details oeffnen"
- Detailansicht als Overlay mit Medium, Beschreibung, Tags, Kennzahlen
- Auswahlmodus mit Auswahlkreisen und Sammelaktion "Zur Sammlung
  hinzufuegen"
- Escape schliesst der Reihe nach Detailansicht, Menue, Auswahlmodus

Karten haben jetzt eine id; Merken, Ausblenden, Auswahl und Detail
referenzieren darueber statt ueber einen Index. Die Profil-Galerie zeigt
dadurch genau die gemerkten Karten, die Kennzahl "Gemerkt" zaehlt sie.
Beispieldaten auf 10/8/6 Karten erweitert, damit das Raster traegt.

Offen und im README vermerkt: Suche ist weiterhin eine Attrappe,
Ausblenden ist fluechtig, Nachladen fuer echte Datenmengen fehlt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:38:14 +02:00
347fe7008a Reskin auf Pinterest-Desktop-Optik, Funktionen unveraendert
Vorlage ist die Pinterest-Web-App. Geaendert wurde ausschliesslich die
Gestaltung: state, Tastaturkuerzel, deck/flash/commit/undo, die
Drag-Handler und die Beispieldaten sind Byte-fuer-Byte identisch.

- Hell statt dunkel: weisser Grund, Text #111, roter Akzent #e60023
  statt Mint auf Tannen-/Weinrot-Verlauf
- Sidebar (236px, beschriftet) wird zur 80px-Icon-Rail, Einstellungen
  sitzt unten; Zustaende ueber .rail/.rail.active statt inline, weil die
  dc-Laufzeit style-hover mit !important setzt und den aktiven Button
  sonst beim Hover aufhellt
- Kopfzeile bekommt das Pinterest-Suchfeld (Pille, 48px) plus Konto;
  Tabs wandern in eine eigene Leiste mit 3px-Unterstrich
- Karten randlos mit 16px-Radius; Merken-Pille erscheint beim Hover oben
  rechts, dazu ein Scrim - beides an :hover/:focus-within der Karte
- Profil-Galerie wird Masonry (columns:5, gestaffelte Hoehen)
- Nur noch Manrope, JetBrains Mono und backdrop-filter entfallen

Der Tageszaehler "Karten gesichtet" hat mit der schmalen Rail seinen
Platz verloren und sitzt jetzt in der Tab-Leiste.

Offen: das Suchfeld ist eine Attrappe - die App hat keine Suche. Im
README unter "Offene Punkte" vermerkt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:25:15 +02:00
113 changed files with 21377 additions and 531 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "brandloop-web",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "client", "run", "web"],
"port": 8081
}
]
}

View File

@@ -0,0 +1,546 @@
---
name: character-builder
description: "Character and outfit builder for Higgsfield image generation. Three workflows: (1) BUILD FROM SCRATCH — photoreal humans via a Higgsfield Soul face test followed by a detailed 3:4 chest-up lock in Nano Banana Pro or Higgsfield GPT-2, or anime/cel-shaded characters built directly in Banana Pro with a full cel render stack; (2) ADDITIONS TO AN EXISTING CHARACTER — hair changes, makeup registers, piercings, tattoos, scars, expression sets, and re-locks that extend a character without breaking identity; (3) OUTFIT BUILDER — wardrobe proposals, direct 9:16 builds on the locked character, invisible-mannequin garment plates for complex looks, and 3-panel character sheets. Covers the flat 18% gray plate, the flattering-realism ceiling, the cel-shade render stack, identity-marker locking, and reference reading. Use for building a new character, locking a face, changing a character's hair or markings, designing an outfit, generating a character sheet, or any character reference still."
---
# Character Builder — Higgsfield Character & Outfit Grammar
The locked grammar for building characters and their wardrobes as reusable image assets. A character is not one picture — it is a canonical face lock, a set of identity markers, and a growing library of outfits, all anchored so that every downstream image and video prompt renders the same person.
Three workflows:
| Workflow | Use when |
|---|---|
| **Part 1 — BUILD FROM SCRATCH** | No reference exists. The character is being invented. |
| **Part 2 — ADDITIONS** | The character is locked. Something is being changed or added to them permanently. |
| **Part 3 — OUTFIT BUILDER** | The character is locked. A new fit is being designed and put on them. |
Never skip forward. An outfit cannot be built on a character whose face isn't locked. An addition cannot be made to a character who doesn't exist yet.
---
## CORE PHILOSOPHY
No plastic. No CGI sheen. No 3D-render look. No commercial gloss. No AI-generic skin or hair.
### The two axes — separate these, always
Photoreal character work runs two things that sound like one thing and are not:
**Axis 1 — BIOLOGICAL REALISM: fully on.** The subject must read as a real living person. Real pore texture, real peach fuzz at the jaw and hairline, real subsurface scattering, hair rendered strand by strand with flyaways and baby hairs, real fabric weave and weight and drape, real metal surface on jewelry, real eyes with depth and moisture. This never comes off.
**Axis 2 — PHOTOGRAPHIC CAPTURE BEHAVIOR: off on every character plate.** A character plate is not a photograph. It carries no lighting, no shadow, no lens character, no atmosphere, and no capture artifacts. No key direction, no shadow side, no cast shadow, no contact shadow, no falloff on the background, no light spill, no bokeh, no depth-of-field falloff, no vignette, no flare, no film grain.
The two get tangled constantly because "photorealistic" sounds like it means both. It doesn't. Writing *"photographed on a real camera by a real photographer"* into a character plate switches on Axis 2 along with Axis 1 — and Axis 2 is exactly what poisons a reference. The subject should look like a real person, rendered flat, against nothing.
**Why Axis 2 is off.** These are references, not finished frames. Any lighting information baked into a plate — a cheek triangle, a nose shadow, a contact shadow under the feet, a soft falloff behind the shoulder, a warm bleed on the backdrop — is inherited and amplified by every downstream generation that reads it, and it fights whatever lighting the actual scene wants. The plate carries zero lighting. The scene prompt or the video prompt does all the lighting later.
### The flattering-realism ceiling — LOCKED, every face, every mode
Full skin realism is always on: visible pore texture, peach fuzz, subsurface scattering, hair flyaways, the matte finish that carries the anti-plastic look. But realism never means unflattering. No acne, no blemishes, no prominent spots, no scarring the user didn't ask for, no enlarged or cratered pores, no rough bumpy texture, no aggressive detail that reads clinical. The texture is fine, soft, even, and natural. Matte is the anti-plastic lever; fine-and-even is the flattering lever. Both run together. Where they conflict, resolve toward flattering — a face should always look good.
### Cel-shaded characters
The philosophy inverts. Every image reads as hand-drawn animation production art. Clean deliberate line weight, hard-edged tonal separation, flat color fills, no photographic grain, no lens blur, no airbrush gradients. Axis 1 is off along with Axis 2 — no pore texture, no subsurface scattering, no strand-level hair.
---
## THE FLAT GRAY PLATE (LOCKED DEFAULT FOR ALL CHARACTER WORK)
**18% neutral gray seamless with a completely flat shadowless grade is the locked default** for face locks, character references, outfit plates, character sheets, and prop references. Pure white is the explicit-request exception, used only for a finished standalone still meant to be posted or handed off.
**Why gray.** Pure white and pure black create maximum subject-to-background contrast. Image and video models amplify errors most at high-contrast edges — that's where halo, edge breathing, and contour instability get baked in. A neutral mid-gray ground lowers subject-to-background contrast, giving cleaner edge extraction and far less inherited contrast when the still is read as a reference frame downstream.
**The background stays neutral; the subject does not.** The ground is an even neutral mid-gray, never warm-shifted. But the gray must never cool or neutralize the subject — skin renders at its true natural tone and wardrobe at its true natural color, exactly as under neutral daylight. The relight-from-scratch language and the explicit "warmth preserved and natural, never pale or washed-out or cool-shifted" clause hold this.
**Why flat.** See Axis 2 above. The plate carries zero lighting information so nothing downstream inherits it.
**The background is a field, not a room.** This is the distinction that matters most and the one most often lost. A photographed seamless is a *physical surface* — it takes light, it falls off, it catches spill, it holds the subject's shadow, it has a floor the subject stands on. A character plate background is none of that. It is a flat uniform color field with nothing behind the subject at all: no surface, no floor, no wall, no corner, no seam, no horizon, no plane the subject makes contact with. The subject does not stand on anything and does not stand in front of anything. Nothing the subject does affects the field.
**Zero shadow outside the subject — absolute.** No cast shadow of any kind anywhere in the frame. No contact shadow, no floor shadow, no drop shadow, no ambient occlusion where the body meets the field, no soft darkening behind the shoulders or under the hem, no halo, no edge darkening, no rim of any kind separating the figure from the field. Shading exists only *on* the subject and stops at the subject's silhouette.
**Zero light bleed outside the subject.** No spill, no glow, no bounce, no color cast thrown from the subject or the wardrobe onto the field. No brightening behind the head, no warm bleed off the skin, no reflected color from a bright garment. The field's value is identical at every pixel whether it is beside the subject or in the far corner.
**LOCKED FLAT CLOSE — use verbatim on every photoreal character plate:**
```
The background is a single flat 18% neutral gray field — one uniform value at every pixel corner to corner, identical directly beside the subject and in the far corners, with no seam line, no gradient, no hotspot, no vignette, and no falloff to lighter or darker anywhere in the frame. It is a flat color field, not a photographed backdrop — no surface, no floor, no wall, no corner, no horizon, and no plane the figure stands on or in front of.
Relight from scratch overriding any reference lighting: completely flat shadowless illumination — one enormous soft frontal source at camera position wrapping the subject evenly, matched equal fill from camera-left and camera-right at identical intensity, matched fill from above and below, so both sides of the face read at exactly the same brightness. No key-and-fill ratio, no modelling, no shadow side, no cheek triangle, no nose shadow, no under-chin shadow, no rim light, no hair light, no kicker, no specular hotspot. Extremely low contrast, even, milky, catalogue-flat. Form is described by bone structure, hair strands, and fabric folds alone, not by light and shadow.
Absolutely zero shadow anywhere outside the subject. No cast shadow, no contact shadow, no floor shadow, no drop shadow, no ambient occlusion where the body meets the background, no soft darkening behind the shoulders or beneath the hem, no halo, no edge darkening, and no rim separating the figure from the field. Shading exists only on the subject and stops cleanly at the silhouette. Absolutely zero light bleed outside the subject — no spill, no glow, no bounce, no reflected color cast thrown from the skin or the wardrobe onto the background, and no brightening anywhere behind the figure.
Skin reads matte and velvety — zero shine on forehead, nose bridge, cheekbones, temples, and chin, no oily T-zone. Skin renders at its true natural skin tone and wardrobe at its true natural color, warmth preserved and natural against the neutral gray, never pale or washed-out or cool-shifted by the background. Real peach fuzz at the jaw and hairline, real soft fine even pore texture, subsurface scattering reading as semi-translucent biology, real hair rendered strand by strand with fine flyaways at the hairline, real fabric weave and drape, never plastic, never waxy, never glass-skin, never harsh — fine flattering texture that keeps the face looking good, no acne, no blemishes, no rough pores.
Even sharpness edge to edge across the entire frame. No depth-of-field falloff, no bokeh, no background blur, no lens vignette, no lens distortion, no flare, no bloom, no chromatic aberration, no film grain, no atmospheric haze, no air between the subject and the background.
```
**Four things must appear in every flat close, always:**
1. **Flat field** — one uniform value at every pixel, explicitly a color field rather than a photographed surface
2. **Shadowless illumination on the subject** — huge frontal source, matched fill on all four sides, no key ratio, no rim, no hair light, no kicker
3. **Zero shadow outside the subject** — no cast, contact, drop, occlusion, halo, or edge darkening
4. **Zero light bleed outside the subject** — no spill, glow, bounce, or reflected color onto the field
Miss any one and the plate comes back with lighting information baked into it.
**For sheets:** the flatness must be stated as applying *uniformly across all panels* — same gray value, same shadowless light, no cast shadow in any panel.
---
# PART 1 — BUILD FROM SCRATCH
For any character with no existing reference. Three stages, in order: text spec → style fork → build.
## Stage 1 — The text spec
Let the user describe the character in their own words. Listen, then mirror back a locked spec in plain language covering:
- **Apparent age register** — described by build and bearing, never a number
- **Face** — head shape, bone structure, jaw, chin, cheekbones, brow shape, eye shape and color, nose, lip shape
- **Skin** — tone and finish
- **Hair** — color with every nuance, length, texture, part, styling
- **Body** — build, proportions, posture
- **Default makeup register** — if any
- **Default expression and energy**
- **Identity markers** — piercings with position and metal, scars with placement and size, beauty marks, tattoos, signature jewelry
Iterate the text spec freely until the user says it's locked. Nothing gets generated until they do. Fixing a face in text costs nothing; fixing it after twelve outfits have been built on it costs everything.
## Stage 2 — The style fork
Ask once, before any prompt:
> Photoreal human, or anime / cel-shaded?
The two paths do not share a render register, a tool routing, or a build sequence. Pick one before writing anything.
---
## PATH A — PHOTOREAL HUMAN
Three tools. Ask which before writing anything.
| Tool | Role | When |
|---|---|---|
| **Higgsfield Soul** | Face test pass | Cheap and fast. Throws variations so the user can see the face several ways before committing. |
| **Nano Banana Pro** | Canonical lock — default | Balanced fidelity, reasonable cost, handles any framing. The standard lock tool. |
| **Higgsfield GPT-2** | Canonical lock — maximum fidelity | Sharpest read on micro-detail at face-and-shoulders range. Chest-up only. Higher credit cost. |
**The standard sequence is Soul → Nano Banana Pro.** Run Soul first for the test pass, then lock in Nano Banana Pro. Skip Soul only if the user already knows exactly what the face is.
**GPT-2 substitutes for Nano Banana Pro at the lock step**, not for Soul. It reads pores, lash separation, iris pattern, lip surface, and hair-strand definition at the hairline noticeably sharper than Nano Banana Pro — worth the credits when the face is the entire point of the image, which on a canonical lock it always is.
**Offer GPT-2 whenever a photoreal lock is about to be written**, and whenever the user asks for a highly detailed portrait or headshot on a plain gray field:
> Want the lock on Nano Banana Pro or GPT-2? GPT-2 reads micro-detail sharper — pores, lash separation, iris pattern — but it's chest-up only and costs more credits.
Mention the credit cost **once per conversation**, then drop it.
**GPT-2 constraints:**
- Chest-up framing only. Anything wider loses the fidelity advantage and wastes the credit hit.
- Everything else is identical to the Nano Banana Pro lock — same 3:4 framing, same 17-item description order, same flat plate, same locked close. The tool changes; the grammar does not.
- GPT-2 responds especially well to explicit micro-detail language, so a GPT-2 lock adds one dedicated fidelity paragraph before the flat close (see below).
**Step A1 — Higgsfield Soul face test.** Cheap, fast, loose. Identity essentials only — no fine markers, no makeup detail, no granular anatomy. Soul can't hold those, and asking it to wastes the pass.
**Step A2 — Canonical lock, 3:4 chest-up, in Nano Banana Pro or GPT-2.** Takes the approved Soul plate as the character reference and writes the face out in full detail. This is the image every future prompt anchors to, so it gets the longest, most specific description in the entire skill.
### Step A1 — Soul face test
**Wardrobe lock:** plain black thin-strap camisole for women, plain black ribbed tank for men. No jewelry, no logos, no graphics. Identity-pure.
**Prompt structure — lean, essentials only:**
```
A [heritage] [woman / man] with a [build], [skin tone and finish], [hair color, length, texture]. [Eye shape and color]. [Only large, visually dominant markers — hold fine markers for the lock pass]. [She wears a plain black thin-strap camisole / He wears a plain black ribbed tank], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes to camera, lips closed and relaxed.
The background is a single flat 18% neutral gray field — one uniform value at every pixel corner to corner, no seam line, no gradient, no hotspot, no vignette. It is a flat color field, not a photographed backdrop — no surface, no floor, no wall, no plane the figure stands in front of. Completely flat shadowless illumination — a huge soft frontal source at camera position with matched equal fill from camera-left, camera-right, above, and below, so both sides of the face read at exactly the same brightness. No shadow side, no nose shadow, no under-chin shadow, no rim light, no hair light, no kicker. Absolutely zero shadow outside the subject — no cast shadow, no contact shadow, no drop shadow, no ambient occlusion, no halo, no edge darkening. Absolutely zero light bleed onto the background — no spill, no glow, no bounce, no reflected color cast. Extremely low contrast, even, milky, catalogue-flat. Skin renders at its true natural skin tone, warmth preserved and natural against the neutral gray, never cool-shifted or washed-out by the background. Skin reads matte and slightly diffused, clean and even. Chest-up framing.
Real human skin with visible natural pore texture, fine peach fuzz along the jawline, subtle subsurface scattering on the cheeks and ear edges. Hair rendered strand by strand with realistic natural texture and individual flyaways at the hairline. Even sharpness edge to edge, no depth-of-field falloff, no background blur, no vignette, no flare, no grain.
```
Deliberately lean. No full detail stack, no granular anatomy, no makeup paragraph. Let Soul interpret the face from the essentials so the variations are actually different from each other. The user runs several, picks one, and that becomes the reference for Step A2.
### Step A2 — Canonical 3:4 chest-up lock (Nano Banana Pro or GPT-2)
**This is the most important image in the character's life.** Everything downstream anchors here. Write it long and write it specific.
**Framing:** 3:4 vertical, forehead to upper chest, the face filling most of the frame. A true close-up headshot, not a portrait with air around it. Chest-up, never waist-up — the whole point is resolution on the face.
**What gets written out in full, in this order:**
1. **Framing declaration** — 3:4 chest-up, forehead to upper chest, face filling the frame
2. **Reference anchor** — "the same character as the attached face plate"
3. **Build and heritage** — one clause
4. **Skin** — tone, undertone, finish
5. **Head and face structure** — head shape, forehead, temples, cheekbone height and projection, cheek hollow, jaw angle and definition, chin shape and projection, the line from ear to chin
6. **Eyes** — shape, set, spacing, tilt at the outer corner, lid crease depth and visibility, iris color with its variation across the iris, limbal ring, pupil, the wet line at the inner corner, under-eye structure
7. **Brows** — shape, arch position, thickness, density, hair direction, color relative to the hair
8. **Lashes** — length, density, curl, separation, upper and lower
9. **Nose** — bridge width and straightness, tip shape and projection, nostril shape and visibility, the shadow-free plane transitions
10. **Lips** — fullness upper versus lower, cupid's bow definition, philtrum length and depth, mouth width, corner shape, natural lip color and surface texture
11. **Ears** — shape and set, whether visible under the hair
12. **Hair** — color with every nuance and any tonal variation root to tip, length, texture, part, how it falls, hairline shape, baby hairs
13. **Makeup register** — if any, as a default that swaps freely later
14. **Identity markers** — each with exact placement: piercings by position and metal, beauty marks by location, scars by placement and size, visible tattoos
15. **Wardrobe** — plain black camisole or ribbed tank, no jewelry, no logos
16. **Pose and expression** — squared to camera, head level, neutral relaxed, eyes to camera, lips closed
17. **The LOCKED FLAT CLOSE** — verbatim
**Prompt structure:**
```
A clean cinema-character-reference 3:4 headshot of the same character as the attached face plate, framed from the forehead down to the upper chest with the face filling most of the frame — a true close-up, not a portrait with space around it.
[Build and heritage.] [Skin tone, undertone, and finish.] [Head and face structure — head shape, forehead, temples, cheekbone height and projection, cheek hollow, jaw angle, chin shape, the line from ear to chin.] [Eyes — shape, set, spacing, outer-corner tilt, lid crease, iris color and its variation across the iris, limbal ring, under-eye structure.] [Brows — shape, arch position, thickness, density, direction, color.] [Lashes — length, density, curl, separation, upper and lower.] [Nose — bridge, tip, nostrils.] [Lips — upper versus lower fullness, cupid's bow, philtrum, mouth width, corner shape, natural color and surface texture.] [Ears — shape, set, visibility under the hair.] [Hair — color with every nuance and tonal variation root to tip, length, texture, part, fall, hairline shape, baby hairs.] [Default makeup register, if any.] [Every identity marker with exact placement — piercings by position and metal, beauty marks by location, scars by placement and size, visible tattoos.]
[She wears a plain black thin-strap camisole / He wears a plain black ribbed tank], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes directly to camera, lips closed and relaxed, subtle controlled energy.
[LOCKED FLAT CLOSE — verbatim]
```
The output is the canonical character reference. Every future prompt for this character attaches it.
**GPT-2 addition.** When the lock runs on GPT-2, insert one dedicated fidelity paragraph between the pose line and the flat close. GPT-2 rewards explicit micro-detail language in a way Nano Banana Pro does not:
```
Extreme face fidelity. Real skin texture with visible individual pores, fine peach fuzz catching light along the jawline and upper lip, subtle subsurface scattering across the nose bridge, cheeks, and ear edges reading as semi-translucent biology. Individual lash separation, upper and lower. Real moisture and reflection in the iris with a visible fibrous iris pattern radiating from the pupil and a soft limbal ring at the outer edge. Real lip surface texture with fine natural vertical lip lines. Hair rendered strand by strand at the hairline with visible baby hairs and individual flyaways. Visible fabric weave at the collar and shoulder. Micro-expression detail held in the eye corners and the mouth corners.
```
Everything else in the GPT-2 lock is identical to the Nano Banana Pro lock — same framing, same description order, same flat close, chest-up only.
---
## PATH B — ANIME / CEL-SHADED (single-step, Banana Pro)
Built directly in Banana Pro. No test pass — cel-shaded output is deterministic enough that the detailed prompt gets there in one shot, and a Soul test pass would return a photoreal face that fights the style.
**Go full detail.** Cel-shaded characters live or die on specificity, because the style strips away the incidental realism that would otherwise carry identity. Every simplification is a decision that must be made explicitly.
### The sub-style fork
Ask which register before writing:
| Register | Line | Shading | Eyes | Palette |
|---|---|---|---|---|
| **Modern TV anime** | Clean thin uniform, dark brown-black | 2-tone cel, soft-edged shadow | Large, multi-layer iris, 12 highlights | Bright, moderately saturated |
| **90s cel / retro** | Thicker, slight variation, hard black | 2-tone cel, hard-edged shadow | Rounder, simpler iris, single highlight | Muted, film-print warmth |
| **Manhwa / webtoon** | Very fine, tapering, often colored | 3-tone with soft gradient in hair | Detailed, glossy, heavy lower lash | High-key, pastel, airy |
| **Shonen action** | Bold varying weight, heavy black | 2-tone, hard shadow, strong rim | Sharp, angular, small highlight | Saturated, high contrast |
| **Shojo** | Delicate, tapering, brown-toned | Soft 2-tone, blush-heavy | Very large, deep multi-layer iris, many highlights | Soft, warm, pale |
### What must be specified explicitly
**Line art:** weight and whether it varies · color (pure black, dark brown, or color-matched to the fill it borders) · whether lines close fully or break · line weight on the face versus the body versus the hair.
**Cel shading:** the tone count — two-tone (base plus shadow) is the default; three-tone adds a highlight band · whether shadow edges are hard or slightly softened · where the light comes from, since cel shadows are drawn shapes and need a stated direction even on a flat plate · whether there is any bounce or rim tone at all.
**Color fill:** flat and uniform within each shape · no airbrush gradients · no photographic texture · no noise.
**Eyes — the single most important identity element in cel work.** Specify: overall shape and tilt · iris size relative to the eye opening · the number of iris layers and their colors from outer ring inward · the pupil size and shape · the highlight count, size, and position · whether there is a lower-lid light reflection · upper lash line weight and how far it extends past the outer corner · lower lash presence · the gap between the lash line and the iris top.
**Hair:** rendered in clumped wedges and locks, never individual strands · the number and shape of the front pieces framing the face · the crown shape and how the part reads · the highlight band — its shape, whether it is a single sweep or broken segments, and its color · how the hair overlaps the eyes and brows.
**Face simplification:** how the nose is drawn — a single line, a small wedge, a dot, or a full shape · how the mouth is drawn at rest · whether the ears are drawn in detail or simplified · whether there is a blush and how it is rendered.
**Anti-tells — mandatory negation battery:**
```
No 3D render, no CGI, no photographic texture, no photorealistic skin, no airbrush gradient shading, no soft blurred rendering, no digital painting blend, no lens blur, no depth-of-field falloff, no film grain, no photographic noise, no bloom, no chromatic aberration, no realistic subsurface scattering, no rendered specular highlights on skin.
```
Without that battery the model drifts toward a smooth semi-realistic digital painting that reads as neither anime nor photo.
### Prompt structure — Path B
```
A clean anime character reference illustration, [sub-style register], framed 3:4 from the forehead down to the upper chest with the face filling most of the frame.
[Build and apparent register.] [Skin — flat fill color and undertone.] [Face shape — head silhouette, jaw, chin, cheek line.] [Eyes — shape, tilt, iris size relative to the opening, the iris layers from outer ring inward with their colors, pupil size and shape, highlight count and position, lower-lid reflection, upper lash line weight and extension, lower lash treatment.] [Brows — shape, weight, position relative to the eye, color.] [Nose — exactly how it is drawn.] [Mouth — exactly how it is drawn at rest, width, corner shape.] [Ears — drawn or simplified.] [Blush — present or absent, and how rendered.] [Hair — color as a flat fill plus its shadow tone, the clump and wedge structure, the number and shape of the front framing pieces, the crown and part, the highlight band shape and color, how it overlaps the eyes and brows.] [Identity markers — drawn as deliberate marks with exact placement.]
[Wardrobe — plain black camisole or ribbed tank for an identity-pure plate.] Body squared to camera, head level, neutral expression, eyes to viewer, mouth closed.
Line art: [weight and variation], [color], [closure behavior], [any weight differences between face, body, and hair]. Cel shading: [tone count], [hard or slightly softened edges], light reading from [direction], [rim or bounce tone or none]. All color fills flat and uniform within each shape, no gradients inside a fill.
Background is a single flat [neutral mid-gray / specified color] field, one uniform value at every pixel corner to corner, no gradient, no texture, no vignette. Absolutely zero shadow outside the figure — no cast shadow, no contact shadow, no drop shadow, no ambient occlusion, no halo, no edge darkening — and zero light bleed onto the field.
Hand-drawn animation production art. Clean deliberate line work, hard-edged tonal separation, flat color fills. No 3D render, no CGI, no photographic texture, no photorealistic skin, no airbrush gradient shading, no soft blurred rendering, no digital painting blend, no lens blur, no depth-of-field falloff, no film grain, no photographic noise, no bloom, no chromatic aberration, no realistic subsurface scattering, no rendered specular highlights on skin.
```
---
# PART 2 — ADDITIONS TO AN EXISTING CHARACTER
For a character who already has a canonical reference. Something is being changed or added **permanently** — this is not an outfit, and it is not a one-off scene styling.
## What counts as an addition
| Addition | Notes |
|---|---|
| **Hair color change** | The most common. Full re-lock required. |
| **Hair length or cut change** | Full re-lock required. |
| **New permanent piercing** | Position and metal specified exactly. |
| **New tattoo** | Placement, size, orientation, line weight, and content described in full. |
| **Scar** | Placement, size, age of the scar, raised or flat, color relative to surrounding skin. |
| **Default makeup register change** | Re-lock only if it changes how the face reads at rest. |
| **Expression set** | Adding a second canonical expression to the character's library. |
| **Aging up or down** | Full rebuild, not an addition — go back to Part 1. |
| **Body change** | Full re-lock required. |
## The identity firewall
Every addition prompt must state what is changing **and** state that everything else is held. Without the hold clause, changing hair color drifts the jaw, the eye shape, and the skin tone along with it.
**The hold clause — use verbatim, adjusted for what's changing:**
```
Everything about the character other than [the specific change] is identical to the attached reference and unchanged — the same head shape, the same bone structure, the same jaw and chin, the same cheekbones, the same eye shape and spacing and tilt, the same iris color, the same brow shape, the same nose, the same lips and mouth width, the same ears, the same skin tone and finish, the same build and proportions, and every existing identity marker in the same position. Only [the specific change] is different.
```
## The re-lock rule
**Any addition that changes how the face reads at rest requires a new canonical plate.** Hair color, hair cut, facial piercings, facial scars, and permanent makeup all cross this line. Generate the addition as a fresh 3:4 chest-up lock in the same framing, same wardrobe, same flat plate, and the same style path as the original — and the new plate becomes the character's canonical reference from that point forward.
Additions that don't touch the face at rest — a body tattoo under clothing, a hand tattoo, an ear piercing hidden by hair — can be documented in the character's written spec and written into outfit prompts as needed, without a new face plate.
**Version the plates.** When a character gets a re-lock, both plates continue to exist and refer to different states of the character. Name them so the distinction survives — `[character]-lock-01`, `[character]-lock-02-red-hair`. Always confirm which lock is being anchored to before building an outfit on it.
## Prompt structure — additions
```
A clean [cinema-character-reference / anime character reference] 3:4 headshot of the same character as the attached reference, framed from the forehead down to the upper chest with the face filling most of the frame.
[The change, described in full detail. If it is hair: the new color with every nuance and any tonal variation root to tip, the new length, the new texture, the new part, how it falls, the hairline, baby hairs. If it is a marking: exact placement described relative to a fixed anatomical landmark, exact size, exact orientation, exact color and finish, whether raised or flat, line weight if it is a tattoo.]
[The hold clause — verbatim.]
[Wardrobe — the same plain black camisole or ribbed tank as the original lock], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes directly to camera, lips closed and relaxed.
[LOCKED FLAT CLOSE for photoreal, or the cel render close for anime — matching the original build path.]
```
## Expression sets
A character can carry more than one canonical expression. Useful for characters who appear across a lot of material with a consistent emotional register.
Build them as a 3-panel sheet in one image so the identity holds across all three: neutral at rest, the character's signature expression, and one extreme. Each panel is the same framing, the same lighting, the same wardrobe — only the face changes.
State per panel exactly which muscles move: brow position, lid aperture, mouth corner direction, jaw, whether teeth show, where the eyes look. Never name the emotion alone. *"Brows drawn slightly together and down at the inner ends, upper lids lowered a fraction, mouth corners level and pressed, jaw set"* renders. *"Angry"* does not.
---
# PART 3 — OUTFIT BUILDER
For a character with a locked face. Three steps. Skipping step 2 is the single most common cause of an outfit rendering wrong.
## Step 1 — The wardrobe proposal (text only)
Before any image, write the outfit out in plain text and wait for approval. Never combine a wardrobe proposal with an image prompt in the same message.
Cover, head to toe:
- **Every garment** — color, fabric, weave or finish, cut, fit, neckline, sleeve, hem position, closures, how it sits on the body
- **Layering** — what goes over what, what's open, what's tucked
- **Structural detail** — cutouts, panels, boning, ruching, pleats, distressing, hardware
- **Footwear** — style, material, heel height and shape, how it interacts with the hem
- **Jewelry** — every piece, metal, scale
- **Accessories** — bags, belts, gloves, eyewear, headwear
- **Nails** — length, shape, finish
- **Hair styling for this outfit** — if it differs from the character's default
- **Makeup for this outfit** — if it differs from the default
Iterate on text until locked. Text iteration is free.
## Step 2 — Build the outfit
Two paths. **Default to the direct build and encourage it.** Only route to the mannequin path when the outfit genuinely earns it.
### Path 1 — DIRECT ON THE MODEL (default, encouraged)
Build the outfit straight onto the locked character in one generation, **9:16 vertical, full body**. The character reference is attached; the prompt writes the wardrobe.
This is the right call for most outfits. It's one generation instead of several, the fit reads on the character's actual proportions from the start, and there's no compositing step to degrade identity.
**Why 9:16.** A full-body outfit reference needs the frame to be taller than it is wide. 9:16 gives the garment the vertical pixel budget — hem lengths, break at the ankle, footwear, and drape all read at usable resolution instead of getting squeezed into the middle of a square.
**Prompt structure — Path 1:**
```
A full-body character reference of the same man as the attached character reference, standing [pose], framed head to toe in a tall vertical 9:16 frame with the full figure and the footwear entirely within the frame.
[Identity restated briefly — build, skin, hair, face register. Two or three clauses only; the reference carries the rest.]
He wears [the full outfit head to toe — every garment with color, fabric, weave or finish, cut, fit, collar or neckline, sleeve, hem position, closures, structural detail, how it sits and moves on the body, layering, footwear, jewelry, accessories, nails].
[Pose and expression.]
[LOCKED FLAT CLOSE — verbatim, adjusted to full-body framing.]
```
### Path 2 — INVISIBLE MANNEQUIN GARMENT PLATES (complex outfits only)
For outfits with multiple custom pieces, unusual construction, heavy hardware, or a specific designed cut that a direct build would round off toward something generic.
**Step 2A — Build each piece in Nano Banana Pro on an invisible mannequin.** One plate per garment, or grouped when pieces read together. The garment holds its full three-dimensional worn shape with no body inside it — collar and cuffs holding their own volume, the opening reading as an empty dark hollow looking down into the inside of the garment, real fabric tension across the chest and shoulders, natural drape and weight, but nothing emerging from any opening.
Because there's no face and no body competing for attention, the entire prompt can be about the garment. This is what makes it hold custom construction that a direct build loses.
**Prompt structure — Step 2A:**
```
A garment reference of a single [garment type] worn on an invisible body, floating in frame and holding its full three-dimensional worn shape.
[The garment in complete detail — color, fabric, weave or finish, cut, fit, collar or neckline construction, sleeve, hem, closures, seams, panels, hardware, pockets, print or pattern with its scale and layout, lining if visible.]
There is no head, no neck, no hands, and no body anywhere in the frame — the garment reads as worn by an invisible figure with full volume, natural drape, and real fabric tension across the chest and shoulders, the collar and cuffs holding their own three-dimensional shape, every opening reading as an empty dark hollow looking down into the inside of the garment with the inner back of the fabric faintly visible. No stump, no skin, no cut edge, no anatomy, no mannequin form, no hanger, no stand, not blurred, not faded, no ghosting, no transparency.
[LOCKED FLAT CLOSE — verbatim.]
```
**Step 2B — Bring the garment plates to Banana Pro with the character.** Attach the character reference plus every garment plate, and compose the full look on the character in one generation.
```
A full-body character reference of the same man as the character reference, wearing the garments from the attached garment references, framed head to toe in a tall vertical 9:16 frame.
Keep every garment exactly as shown in its reference — the same color, the same fabric, the same cut and fit, the same collar and cuffs, the same hem position, the same closures, the same hardware, and the same print or pattern at the same scale. Keep the character's identity exactly as shown — the same face, the same bone structure, the same eye shape and color, the same skin tone, the same hair, and every identity marker in the same position.
[Layering — what goes over what, what is open or closed, what is tucked.] [Footwear, jewelry, and accessories not covered by a garment plate.] [Pose and expression.]
[LOCKED FLAT CLOSE — verbatim, adjusted to full-body framing.]
```
### Choosing the path
**Direct (Path 1)** — a shirt and trousers, a dress, a suit, a jacket over a tee, a matching set, anything the model already understands as a category. Most outfits.
**Mannequin plates (Path 2)** — heavily constructed pieces, unusual silhouettes, custom hardware, garments whose whole point is a specific detail that would get averaged away, or a look where the user wants each piece designed and approved independently before it's committed to the character.
**When in doubt, run Path 1 first.** It's one generation. If the garment comes back rounded off toward generic, that's the signal to fall back to Path 2 for the piece that failed — and only that piece.
## The 3-panel character sheet
Built only after an outfit plate exists on the locked character and the user is happy with it. One image, three vertical panels, one prompt.
### Attach the outfit render alone — not the face lock
**Default: the approved full-look outfit render is the only reference.** It already carries the face, the hair, the skin, the build, the wardrobe, and every accessory in one image, all of them already agreeing with each other. It is a strictly better reference for a sheet than the outfit render plus the face lock, because it needs no reconciliation.
**Adding the canonical face lock alongside it is usually a downgrade.** Two references means two sources for the same face, and the model has to reconcile them — different framing, different crop, different neutral wardrobe, different hair state. That reconciliation costs attention that should be going to the panel geometry and the garment, and on a three-panel sheet the attention budget is already split three ways. Overloading references is one of the most reliable ways to get a mushy sheet.
**Attach the face lock as a second reference only when there's a specific reason:**
- Identity has visibly drifted in the outfit render and needs pulling back toward canon
- The outfit render obscures the face — heavy eyewear, a mask, a hood, deep shadow, a turned head
- The outfit render came out soft or low-detail in the face
- A previous sheet attempt returned a face that doesn't match
- The user explicitly wants the sheet re-anchored to the original lock
When it is attached, say what each reference is for in the prompt body so the model doesn't average them: *"the face, bone structure, and skin tone come from the face reference; the wardrobe, hair styling, and accessories come from the look reference."*
**The same economy applies everywhere in this skill.** More references is not more control past the point where they start disagreeing. Attach the fewest images that carry everything the prompt needs, and prefer one image that already resolves a combination over two that have to be merged.
### Layout
1. **LEFT — full body front, headless.** Full headroom preserved — the head is *removed from the body*, not cropped by the frame edge. Isolates the garment, the silhouette, and the proportions with no facial data competing.
2. **CENTER — full body rear, head attached.** Hair fall, back construction, hem, footwear all readable from behind.
3. **RIGHT — tight chest-up face lock.** Just above the crown down to the collarbones. The face fills the panel. This is the identity anchor and it must be tight.
**The headless cut — pick by garment:**
*Variant A — ghost mannequin.* For structured or closed necklines sitting at or above the collarbone — collars, crew necks, ribbed tanks, turtlenecks, hoods, jacket collars, keyholes. No head and no neck at all; nothing rises above the shoulder line. The collar holds its own three-dimensional shape and the opening reads as an empty dark hollow looking down into the inside of the garment, the inner back of the fabric faintly visible.
*Variant B — clean neck cut.* For garments with no neckline to hollow — strapless, halter, spaghetti strap, deep cowl, scooped or plunging. The neck rises a short way from the shoulders and terminates in a clean flat sharply defined horizontal edge at the base of the throat, like a headless dress-form mannequin.
**Both variants ship the same suppression stack:** not blurred, not faded, not dissolving, no wisps, no smoke, no ghosting, no transparency in the body, no stump, no anatomy detail at the cut, no blood. And the hair goes with the head — no hair falling across the chest or shoulders in the left panel.
**Critical rules:**
- One prompt, one code block, one image. Never three separate prompts.
- **The approved outfit render is normally the only reference.** Add the face lock only for a stated reason, and say what each reference carries when you do.
- Identity and wardrobe described **once** in opening paragraphs, applying to all three panels.
- Each panel describes only what differs — angle, framing, head state.
- **Skin-tone consistency clause is mandatory.** Rear panels drift darker without it: *"skin renders at its true natural skin tone, identical in value and hue across the face, arms, and body in every panel, never darkened, never tanned, never pale or washed-out."*
- Backdrop and lighting uniform across all three cells, stated explicitly as uniform.
- Every panel carries its position label so the grid composes correctly.
---
## READING REFERENCE IMAGES
When the user uploads references, extract everything visible by **visual description only** — never use names, never invent what isn't there.
- **Hair** — color with every nuance, length, style, texture, part, styling treatment, accessories
- **Makeup** — skin finish, coverage register, brow shape and density, eye treatment, lashes, lip, cheek, face jewelry, freckles or beauty marks *only if visible*
- **Wardrobe** — every garment top to bottom: fabric, color, fit, structural detail, neckline, sleeve, hem, layering
- **Jewelry and accessories** — every piece, metal, scale
- **Body markers** — piercings and tattoos *only if visible*, nail length and finish
- **Pose and energy** — body angle, weight, hands, expression register
**No-invention rule.** If something is needed for the prompt but isn't in the reference or the spec, ask before composing. Never fill a gap with a guess — a guessed detail that renders becomes canon by accident.
---
## UNIVERSAL RULES
1. **No character names in prompt output.** Describe by hair, wardrobe, and identity markers. The tools don't know names; visual descriptors survive across prompts.
2. **No aspect ratios in prompt output.** Set in the Higgsfield UI. Describe framing in words — "3:4 chest-up headshot," "full body," "tight close-up."
3. **No `@image` tags or placeholders.** Attachment happens in the UI. The prompt is text-only and refers to references in prose — "the attached face plate," "the character reference," "the outfit reference."
4. **No internal production context.** Every prompt is standalone and self-contained.
5. **Pure visual description only.** No meta-commentary, no explanation of intent, no references to the medium.
6. **No teeth-showing smiles** unless explicitly requested. Default is model face-card neutral, or a slight closed-lip smirk.
7. **Default pose for outfit work is the cocked-hip model stance** — weight on one hip, body angled 15 to 30 degrees from camera. Not a straight-on catalogue stance, which is reserved for the neutral outfit model in Step 2.
8. **Age-blind.** Describe by build, bearing, role, and wardrobe — never by age word or number.
9. **Brand names, text, and graphics are written verbatim.** A specific label, wordmark, slogan, or logo gets named and described — shape, color, placement, legibility. Naming the thing renders the thing.
10. **Flat grade on every character plate and sheet.** Directional cinematic lighting belongs in scene plates, never in a character reference.
11. **Single fenced code block on output.**
---
## DELIVERY FORMAT
Three parts:
**1. Bolded title line.** Names what's being built and which step it is.
`**Face lock — step 2 of 2, Nano Banana Pro 3:4 —**`
**2. Numbered reference list.** One line per attached reference. If none: `No references — text-only build.`
**3. One fenced code block.**
**Tool routing goes in the title**, so the user knows where to paste: Soul, Nano Banana Pro, or Banana Pro.
**On iterations — deliver directly.** Any tweak to an already-approved prompt (palette, framing, pose, lighting, a single garment swap, a styling nudge) ships as the revised full prompt with no confirmation bullets. Re-check only on a full scope change — a new character, a new outfit, a new mode, or a new style path.
**Wardrobe proposals are the exception to deliver-by-default.** A new outfit always gets its text proposal approved before any image prompt is written, and the two never ship in the same message.
---
## PRE-DELIVERY PASS
- [ ] Which part of the skill applies — build from scratch, addition, or outfit — and the prerequisite for that part exists
- [ ] For a new character: text spec locked and approved before any generation
- [ ] Style path forked and the render register matches it throughout
- [ ] Photoreal builds route Soul for the test pass, and Nano Banana Pro or GPT-2 for the lock — with the GPT-2 option offered and its credit cost mentioned once per conversation
- [ ] GPT-2 locks are chest-up only and carry the extreme-fidelity paragraph
- [ ] The lock pass is 3:4 chest-up with the face filling the frame, never waist-up
- [ ] Every facial plane, the eyes in full, and every identity marker with exact placement are written into the lock
- [ ] Anime builds carry line spec, cel tone count, shading direction, full eye construction, hair clump structure, and the anti-tell negation battery
- [ ] Additions carry the hold clause and trigger a re-lock if they change the face at rest
- [ ] Outfits went through the text proposal, then a direct 9:16 build on the locked character — or invisible-mannequin garment plates first if the outfit was complex enough to earn it
- [ ] Character sheets have identity and wardrobe described once, the correct headless variant, and the skin-tone consistency clause
- [ ] Reference economy — the fewest images that carry what the prompt needs, and no canonical face lock stacked onto a sheet without a stated reason
- [ ] Axis 1 on and Axis 2 off — biological realism written in full, zero photographic capture behavior anywhere in the plate
- [ ] Flat gray field, shadowless light on the subject, zero shadow outside the subject, zero light bleed onto the field — stated per panel on sheets
- [ ] No names, no aspect ratios, no placeholder tags, no meta-commentary
- [ ] Bolded title with tool routing, numbered reference list, one code block
**Repair pass:**
- Face drifting between outfits → the lock plate isn't tight enough; rebuild it chest-up with fuller facial description
- Outfit rendering generic or rounded off → fall back to an invisible-mannequin garment plate for the piece that failed, and only that piece
- Rear panel skin darker than front → the skin-tone consistency clause is missing
- Shadow appearing under the feet or behind the shoulder → the zero-shadow-outside-the-subject clause is missing or too short
- Background brightening or picking up color near the figure → the zero-light-bleed clause is missing
- Background reading as a lit wall or floor rather than a flat field → the "color field, not a photographed backdrop" line is missing
- Modelling appearing on the face → one of the four flat requirements is missing
- Grain, vignette, or background blur appearing → capture-behavior language leaked in from Axis 2
- Anime output reading semi-realistic → the anti-tell negation battery is incomplete
- Hair color change also changed the face → the hold clause is missing or too short
- Identity marker in the wrong place → it was described relative to nothing; anchor it to a fixed anatomical landmark
- Sheet coming back mushy or averaged → too many references disagreeing; drop back to the single approved outfit render
- Face on a sheet drifting off canon → this is the case where the face lock earns its slot; attach it and state what each reference carries

View File

@@ -0,0 +1,637 @@
---
name: cinema-director
description: "Cinema director for Seedance and Higgsfield video prompts. Writes production-grade multi-shot prompts in a locked house format — shot/duration header, capture cadence block, ALL-CAPS CRITICAL directive blocks, per-subject Subject Locks with wardrobe restated, Corps/Crew/Prop Locks, World Plate, Atmosphere, timecoded SHOT blocks, Cross-Frame Rules, Last Frame, Sound Bed, and a merged Camera & Capture Realism closer. Covers cinema film capture (24fps, 180-degree shutter, anamorphic, 35mm grain) and phone/BTS capture (30fps, rolling shutter, HDR-flat). Includes the lipsync bilabial closure protocol, strobe grammar, still-haze atmosphere language, an FOV degree lens anchor, skin protection, and diegetic-only audio rules. Use whenever the user wants a Seedance or Higgsfield video prompt, a music video shot, a behind-the-scenes clip, a performance or lipsync sequence, an action or atmospheric sequence, or asks to break a scene into shots for video generation."
---
# Cinema Director — Seedance / Higgsfield Video Prompt Grammar
The working prompt grammar for cinematic AI video. Every prompt is a production document: who is in frame, what they wear, where they stand, what moves, how the camera behaves, what the light does, what the air does, what the audio is, and what the last frame looks like.
The block order, the ALL-CAPS directive convention, the wardrobe-restatement rule, and the negation batteries all exist because their absence caused specific, repeated failures.
---
## CORE PHILOSOPHY
No plastic. No commercial gloss. No LED-panel-on-a-soundstage energy. No Instagram sharpness.
Every frame reads as captured on a real camera operated by a real body. Film-emulated, imperfect, analog warmth in the highlights, blacks that hold detail. Editorial grade, not commercial. Glass with character. Real fabric, real skin, real haze, real grain.
**A great prompt is a production document, not a beautiful sentence.** If a word doesn't produce a visible pixel or an audible sound, cut it.
**Length discipline.** A four-shot sequence with four Subject Locks should land around 1,0001,600 words. Longer than that and the directive blocks start losing weight against the descriptive body. Every line must be a lock, not a flourish.
---
## WRITE THE VISIBLE
The model is a physics engine, not a mood board. It renders things it can see and count. Mood words evaporate.
- ❌ "she looks stressed" → ✅ "shoulders lift, jaw locks, exhales through the nose, eyes fix on the door"
- ❌ "the alley feels dangerous" → ✅ "one buzzing sodium bulb 30 meters back, wet brick, standing water, no other figures"
- ❌ "fast chase" → ✅ "carves through traffic at 110 km/h, leg dragging outside the lane line on turn-in"
- ❌ "she looks massive next to him" → ✅ "she stands the height of two of him stacked"
**Measurables the model reads:** speed in km/h · atmosphere as density plus visible depth · scale by stacking humans · direction from the camera's point of view ("screen-left") · emotion rendered in muscle · environmental contact rendered physically.
---
## PHRASING: POSITIVE DEFAULT, SANCTIONED NEGATION BATTERIES
Default to stating what happens, not what shouldn't. Negative language usually weakens the signal — the model sees the noun and rounds toward it.
**Five negation batteries are locked and mandatory**, because they suppress known failure modes that positive phrasing does not fix:
1. **The atmosphere battery** — the still-haze negation list
2. **The cadence battery** — the anti-interpolation list
3. **The realism tail** — the closing "no CGI, no rendered look, no AI smoothness…" run
4. **The population lock** — "no other people anywhere in frame" when a scene must be empty
5. **The on-screen text suppression** — its own standing ALL-CAPS directive block, high, never in Last Frame
Everywhere else, ship positive.
---
## THE TWO CAPTURE FAMILIES
Every prompt is written in one. The user picks; don't switch mid-prompt unless they ask.
### Family A — CINEMA CAPTURE
Native 24 fps, true 180-degree shutter, real 1/48 second exposure on every frame. Genuine photographic motion blur. Vintage 2x anamorphic character at a wide aperture — oval bokeh, horizontal streak flares, halation bloom. Shallow depth of field. Color-negative rendition with fine 35mm grain. Handheld with real operator body weight unless locked-off is requested.
### Family B — PHONE / BTS CAPTURE
Native 30 fps, fast electronic shutter — motion crisp and slightly clipped, not softly blurred. Digitally sharp with heavy edge sharpening and high micro-contrast. Deep phone depth of field. Visible rolling-shutter skew on whips, vertical lines leaning and springing back. Aggressive automatic exposure that visibly hunts and pumps. Automatic white balance shifting between zones. Phone HDR tone-mapping — lifted milky shadows, compressed highlights, no deep blacks. Slightly overcooked saturation. Fine digital luminance noise, **not** film grain. Hard clean digital flare with tight star points. Stepped digital zoom when magnifying.
**Phone capture must explicitly kill the film grammar.** Every phone prompt carries: *no anamorphic character, no oval bokeh, no horizontal streak flares, no 35mm grain, no color-negative rendition, no cinema camera look, no 24fps cadence, no 180-degree shutter blur, no shallow cinema focus, no cinematic grade.* Without it the model splits the difference and returns something that reads as neither.
---
## MODE SELECTION (INTERNAL ONLY)
Five modes govern movement, diffusion, grade, and palette. **The mode is never written into the prompt.** Seedance and Higgsfield don't read it. It's a selection tool that decides what the other blocks say.
| Mode | Use when | Movement | Grade |
|---|---|---|---|
| **M1 Narrative** | Real-world dramatic — streets, interiors, sets, lived-in anywhere | Handheld with operator breath | Color-negative, fine 35mm grain, teal-amber |
| **M2 Studio** | White void, clean studio, editorial, fashion film, portrait | Locked or slow push | Saturated editorial, warm-retained blacks |
| **M3 Action** | Combat, chase, stunts, debris, smoke | Handheld and shaky throughout | Heavier low-light grain, dusty haze |
| **M4 Performance** | Stage, arena, lipsync, singing, choreography to camera | Mixed handheld pit and orbital, hard cuts | Stage color cast, streak flares, heavy haze |
| **M5 Atmospheric** | Environment plates, mood, bodies-as-texture, no dialogue | Locked-off, slow push, or wide roaming | Palette-driven, atmospheric |
Once selected, the mode expresses itself through the camera physicality register, the grade language in the closing block, and the diffusion and haze density — never through a label.
---
## DYNAMIC REGISTER (ENERGY DIAL)
Mode says *what kind of scene*. Dynamic register says *how hot the camera runs*. It governs the camera physicality ladder, the cut rate, the cant range, and how much of the frame is allowed to be still. Set it before writing a single block — it changes every downstream choice.
| Tier | Name | Camera | Cant | Cuts | Frame stillness |
|---|---|---|---|---|---|
| **1** | **Composed** | Locked-off, or an extremely slow push or pull. Tripod-weighted. | 0° | 12 shots over the full runtime, or a oner | Long held frames. Stillness is the subject. |
| **2** | **Elevated** | Gentle handheld with breath and float, or slow deliberate dolly and crane moves. Unusual but calm angles — high overhead, low tabletop, tight profile. | 310° | 35 shots, 2.54s each | Frames settle and hold before moving on. |
| **3** | **Kinetic** | Heavy handheld. Tracking, orbiting, pushing. Operator weight readable. | 1225° | 46 shots, 1.52.5s each | Every frame mid-move, but the eye can still land. |
| **4** | **Violent** | Violent handheld. Punching in, ripping back, whipping, hard surges. | 2545° | 46 shots, 1.52s each, plus internal surges | Nothing settles. The frame never lands. |
**Deduce first, ask only if genuinely split.** Read the user's description for these cues:
- **Toward Tier 12:** grief, memory, waiting, ritual, morning, solitude, landscape, portrait, intimacy, an object being handled carefully, a room before anyone arrives, dialogue that matters, the words "quiet," "still," "slow," "beautiful," "elegant," "peaceful"
- **Toward Tier 34:** a beat drop, choreography, a chase, a fight, a crowd, a reveal, a crash, strobe or flashing light, a named BPM, the words "hype," "chaotic," "aggressive," "energy," "hard," "go crazy"
Ambiguous cases are real: a character walking into a room could be Tier 1 dread or Tier 4 arrival. A performance could be Tier 2 restrained or Tier 4 full-out. When the description genuinely supports two tiers, ask once, in one line, offering the two that actually fit:
> "Reading this two ways — is it composed and held, or do you want the camera hot and punching?"
Never ask when the description has already answered. Never offer all four when only two are live.
**Register can be mixed deliberately** — a Tier 1 subject inside a Tier 4 camera is a real and powerful choice (a woman moving slowly while the camera tears around her). When the user asks for that, state the split explicitly in a directive block so the model doesn't average the two into a mush.
---
## REFERENCE TAGS
References are indexed image and video tags in the platform's native syntax:
- Images: `@[Image 1](image_1)``@[Image 9](image_9)` — nine maximum
- Video/audio: `@[Video 1](video_1)`
**Ordering convention:** character sheets first in narrative order, then group/corps wardrobe sheet, then prop sheets, then environment plate last. Audio or video reference last of all.
**Every character gets their own slot.** A character visible inside an environment plate still gets a separate canonical reference and a separate Subject Lock. The plate carries the world; the sheet carries identity.
**The corps or group wardrobe sheet is its own slot**, separate from any formation or staging plate. Two different references — collapsing them loses the wardrobe.
**Renumber cleanly.** If a reference is added, renumber every tag in the body and reissue the list. Never leave a gap or a stale index.
---
## DELIVERY FORMAT
Three parts, in this order:
**1. Bolded title line with runtime.** Names the scene and states the duration.
`**Fur couch — hype ending — 8s**`
**2. Numbered reference list.** One line per reference, as a numbered list — never a prose paragraph.
```
1. @[Image 1](image_1) — [what it is]
2. @[Image 2](image_2) — [what it is]
3. @[Video 1](video_1) — [what it is]
```
**3. One fenced code block** containing the prompt.
Nothing else. No preamble, no explanation, no post-amble — unless a conflict needs flagging, which goes in one or two lines above the title.
**On iterations — deliver directly.** Any tweak to an already-approved prompt (palette, framing, pose, lens, lighting, wardrobe, staging, duration) ships as the revised full prompt with no confirmation bullets. Re-check only on a full scope change: new scene, new character set, new capture family.
**Always ship the full prompt.** Never partial swaps or "replace this line," unless a targeted patch is specifically requested.
**Split rather than overload.** Two camera vantages on the same action are two prompts. A sequence past 15 seconds is two prompts. Say so and deliver both.
**Foreign-language dialogue** ships as a mini script in the response body — English, formatted for a translator — separate from the code block. Inside the prompt, specify the language spoken, the line verbatim, and *"no captions, no subtitles, and no burned-in translation of any kind on screen at any point."*
---
## BLOCK ORDER (LOCKED)
Inside the single fenced code block, in this order:
```
1. Shot count + total duration + per-shot timecodes + cut policy + speed policy
2. CAPTURE CADENCE (cinema) or CAPTURE FORMAT (phone)
3. NO ON-SCREEN TEXT — CRITICAL (mandatory, always first directive block)
3b. Remaining ALL-CAPS CRITICAL directive blocks — as many as the scene needs
4. Subject Lock — @[Image N](image_n) (one per named character)
5. Corps Wardrobe Lock / Crew Lock (group uniforms)
6. PROP LOCK — [name] (any prop that must render exactly)
7. World Plate (anchored to the plate tag)
8. THE ATMOSPHERE — CRITICAL
9. SHOT N — 0.0 to X.Xs blocks (one per shot)
10. Cross-Frame Rules
11. Last Frame
12. Sound Bed
13. Camera & Capture Realism (merged closer — always last)
```
No mode line. No prose between blocks.
---
## BLOCK 1 — SHOT HEADER
States shot count, total duration, every shot's in and out point, cut policy, and speed policy.
```
4 shots. Total duration 8 seconds — shot 1 runs 0.0 to 2.0s, shot 2 runs 2.0 to 4.0s, shot 3 runs 4.0 to 6.0s, shot 4 runs 6.0 to 8.0s. Hard cuts between them, no transitions, no dissolves. All shots at normal speed.
```
Single take: `1 continuous shot. Total duration 10 seconds, no cuts, no transitions, no dissolves. Normal speed throughout.`
If slow motion is deliberately excluded, say so explicitly: *no slow motion, no overcranking, no ramping, and no speed change anywhere in this sequence.* The model volunteers slow motion unprompted on stylized material.
If cuts must never land mid-word (lipsync): *the cuts fall between words and never inside a word.*
**Per-shot timing must sum exactly to the stated total. Maximum total duration is 15 seconds.** Anything longer splits into separate prompts.
**Runtime guidance:** 1.52.5s per shot for high-energy cutting · 2.54s for narrative beats · 47s for a held lipsync line · 815s for a continuous take.
---
## BLOCK 2 — CAPTURE CADENCE / CAPTURE FORMAT
The single most important anti-artifact block. Goes second, always, before anything else competes for attention.
**Cinema version:**
```
CAPTURE CADENCE — CRITICAL: captured natively at 24 frames per second with a true 180-degree shutter angle, a real 1/48 second exposure on every single frame. Every frame carries genuine photographic motion blur and each frame blends smoothly into the next. Motion is fluid, filmic, and continuous. Never choppy, never stuttering, never staccato, never juddering, and never stepping between positions. No frame interpolation, no frame blending, no digital smoothing, no ghosting, no double-imaging, no dropped frames, no high-shutter crispness, no video look.
```
**When the scene contains strobe or flashing light**, add the quarantine — it moves the stepping onto the light and off the footage:
```
The stuttering, stepped quality of this sequence comes entirely from the strobe lighting described below — from bodies being revealed only in discrete flashes — and never from broken or choppy footage. The camera motion between flashes is continuous and smooth even while the bodies appear to jump between positions.
```
Without the quarantine the model returns genuinely broken footage.
**Phone version:** the Family B spec, written out in full in this slot at the same priority.
---
## BLOCK 3 — ALL-CAPS CRITICAL DIRECTIVE BLOCKS
The defining feature of this grammar. Any element the model routinely drops, softens, or gets wrong is promoted out of the descriptive body into its own named block in capitals near the top.
Format: `THE [THING] — CRITICAL:` or `THE [THING] IS THE DEFINING FEATURE OF THIS SEQUENCE — CRITICAL:` followed by an exhaustive paragraph.
### The mandatory first directive block
**Every video prompt carries this block, always, as the first directive block — immediately after capture cadence.** It never appears in Last Frame and never appears only at the bottom. Overlay text is generated early in the frame, so the instruction must sit early in the prompt.
```
NO ON-SCREEN TEXT — CRITICAL: no on-screen text of any kind anywhere in frame at any point. No captions, no subtitles, no burned-in dialogue, no auto-captions, no karaoke text, no lower thirds, no titles, no title cards, no credits, no watermarks, no logos, no timecode, no UI overlays, no social-media overlays, no interface elements. The frame is clean of all overlay graphics from first frame to last.
```
This block does not count against the six-block cap.
**Never carve out in-world text inside this block.** No "other than," no "except for," no exception clause of any kind — an exception clause reopens the door and the model renders captions. Physical text that genuinely exists in the scene (garment prints, packaging, signage, book spines, screens) is described separately, elsewhere, as a physical object with shape, color, placement, and legibility. The suppression block stays absolute.
**Weight it hardest on phone, selfie, and talking-head prompts.** Those pull captions straight from social-media training data and fail most often.
| Block | Use when |
|---|---|
| `NO ON-SCREEN TEXT` | **always — mandatory, first, exempt from the cap** |
| `THE SINGING` | any lipsync — first after the text block, top content priority |
| `HER MOUTH IS ALWAYS VISIBLE AND ALWAYS READABLE` | any lipsync, paired with the above |
| `THE STROBE` / `THE LIGHT` | flashing, pulsing, or any non-obvious lighting behavior |
| `THE CAMERA` | when the camera behavior is itself the style |
| `THE STAGING` | when who-stands-where must not drift |
| `THE ATMOSPHERE` | always, when haze is present |
| `THE TONE` | comedic, warm, or emotionally specific scenes that could be misread |
| `THE LANGUAGE` | foreign-language dialogue, no-caption instruction |
| `NOBODY ELSE IS IN THE FRAME` | any scene that must be empty of extras |
| `THE VANTAGE` | when camera distance or position must not creep |
| `THE BEAT` | when the dramatic point of the shot is a specific reversal |
| `THE LIGHT CHANGE` | when lighting shifts mid-take and the shift is the spine |
**Rules:**
- Cap at roughly six. Past that they compete and all of them dilute.
- Order by importance — the model weights early text more heavily.
- Each block is exhaustive within itself. Never distribute one idea across two blocks.
- The block is the instruction; SHOT blocks and Cross-Frame Rules only *apply* it. Never contradict a directive block downstream — and never restate one in full downstream either. Say it once, at full weight, at the top.
**Anti-redundancy, universal.** Every fact belongs to exactly one block. Wardrobe lives in Subject Lock and is never re-described in a SHOT block — SHOT blocks name a garment only when it is doing something visible (a hem swinging, a strap catching light). Lighting lives in its directive block or the World Plate, not both. Atmosphere lives in Block 8 only. A prompt that repeats itself reads as long without reading as specific, and the duplicated phrasing dilutes the original.
---
## BLOCK 4 — SUBJECT LOCK
One discrete block per named character. Never jam two into one paragraph.
```
Subject Lock — @[Image 1](image_1): [build and skin] [face and bone structure] [hair color, length, styling, permanent features] [makeup] [clean-face negations] [wardrobe head to toe] [jewelry, nails] [role or position in the scene].
```
**Target 90140 words per Subject Lock.** Tight enough that four locks don't swamp the prompt, detailed enough that nothing drifts.
**Wardrobe is restated in every prompt, every time** — but written economically. One clause per garment, not one paragraph. Name: color, fabric, cut, and how it sits on the body. Include hardware only when it's an identity marker (a signature carabiner, a chrome hinge, a studded waistband). Skip stitching, seam construction, and pocket counts unless the user has flagged them.
-*"a long-sleeved charcoal-grey cropped top in soft washed matte jersey with a faded mineral-dyed finish, high round neckline, long slim sleeves fitted to the wrist, front panel wide and solid across the chest, hem sitting just below the ribcage, the back left open and held only by the neckline and shoulders"*
-*"a cropped charcoal washed-jersey long-sleeve with a high round neck and an open back, hem just under the ribcage"*
Both lock the garment. The second leaves room for the rest of the prompt to matter.
**Permanent identity features are declared permanent.** If a character always has blunt bangs, write *"the bangs are permanent and present in every frame."* If a mask is never removed, say so here and again in Cross-Frame Rules.
**Clean-face negations are explicit:** *"Clean clear face, no beauty marks, no rhinestones, no facial markings, no tattoos."* The model invents facial detail otherwise.
**Never use character names.** Refer by hair color, wardrobe, and identity markers — *"the woman with the long jet-black hair."* This holds in every block including staging and Cross-Frame Rules.
**Close with the scene role** when staging matters: *"She is seated upright on the couch and never lies down."*
**Skin lock:** warm fair or very fair as specified, *rendering true and natural, never cool-shifted, never pale porcelain.*
### Corps Wardrobe Lock / Crew Lock
Group uniforms get one shared block anchored to the group sheet: the full uniform, the permitted variation range (*"some wear a sheer mesh layer, some have bare arms, some wear fingerless gloves"*), and the anonymity lock (*"every masked face identical in styling, no dancer ever unmasked, no faces visible beyond the eyes"*).
Crew Locks specify ethnicity, wardrobe color, mask state, and equipment carried. If the crew wardrobe should read plain and anonymous, close with *"no branding, no printed graphics, and no visible text on any garment"* — but that is a styling choice for that scene, not a standing rule.
### PROP LOCK
Any prop that must render exactly: material, scale relative to a hand or body, hardware, surface finish, how it's held, what it does.
---
## BLOCK 7 — WORLD PLATE
Anchored to the plate tag: `World Plate: Anchored to @[Image 6](image_6).` Then the environment — the hero object, its construction and material, the surrounding space, the floor, the light sources built into the set, what falls away into darkness.
Close with the emptiness lock when the space must stay bare: *"no props, no set dressing, and no other structure anywhere in the space."*
Re-describe the plate even though it's attached. The plate anchors look; the text anchors persistence across shots.
---
## BLOCK 8 — THE ATMOSPHERE
Haze is the most drift-prone element in the grammar. It gets its own CRITICAL block with a mandatory negation battery.
```
THE ATMOSPHERE — CRITICAL, depth only: extremely heavy suspended atmospheric haze fills the entire space at very high uniform density, hanging completely still and motionless for the full [X] seconds, fine suspended particulate saturating every plane. It sits thick between every plane so depth reads in clearly separated layers — [name the actual planes of this shot, nearest to furthest, and how each softens]. Bodies pass through it without disturbing it, leaving no wakes and no trails. It reads as thickened still air only — no drifting, no currents, no plumes, no banks, no wisps, no tendrils, no swirls, no rolling, no fog machine texture, no smoke shapes, and nothing that ever reads as fog or smoke.
```
**The negation list is mandatory and complete.** Drop items and the corresponding artifact returns. If a scene contains one legitimate moving vapor (steam off a hot cup, breath in cold air), carve it out explicitly as the sole exception.
**Haze is for depth separation, never for mood.** Always name the actual planes of the actual shot.
**Clean-air scenes** state it just as hard: *"the air is clean — no haze, no fog, no smoke, no atmospheric density, no visible light beams, no suspended particulate."*
---
## BLOCK 9 — SHOT BLOCKS
One per shot, timecoded, in four labeled parts.
```
SHOT 2 — 2.0 to 4.0s. [ALL-CAPS SHOT NAME]. Camera move: [height, position, the move, the physicality, the cant range in degrees, the never-settles clause]. Subject action: [what every visible body does, in order]. Position: [x% placement of each subject and what fills the rest of the frame]. Sound: diegetic only.
```
- **Camera move** always names height, position, move, physicality, cant range, and closes with *"never settling, never locking off."*
- **Subject action** covers every visible figure. Silence about a body means it drifts.
- **Position** uses x% for asymmetric compositions, prose for classical ones.
- **Sound** is a one-word pointer; the real audio lives in Sound Bed.
**Every shot differs in angle and height from every other shot.** Restate this in Cross-Frame Rules.
**Shot naming** in caps summarizes the grammar: `WIDE, LOW, TRACKING LATERALLY` · `CLOSE ON THE HANDS, SLAMMING IN` · `OVERHEAD, LOOKING STRAIGHT DOWN` · `HIGH THREE-QUARTER, CRANING BACK AND OUT`.
---
## BLOCK 10 — CROSS-FRAME RULES
The continuity contract. **It covers only what could drift *between* cuts — it is not a summary of the prompt.**
**The no-restatement rule.** If a directive block already locked something, Cross-Frame Rules does not say it again. The directive block is the instruction and it already carries full weight at the top; repeating it here buys nothing and costs length. Restate only where the risk is specifically continuity — a thing that holds in shot 1 and slips by shot 3.
What this cuts in practice:
- Atmosphere is locked in Block 8 — do not re-describe the haze here, only "holds uniform across all shots, never drifts"
- The cadence quarantine is in Block 2 — reference it in one clause, do not rewrite the battery
- Wardrobe is exhaustive in each Subject Lock — here it is one line: identical to each tagged reference, one look per person, no mixing
- Clean-face negations and permanent identity features get a short list of the markers, not their full descriptions
Standard contents, in order — each one line, not one paragraph:
1. Lighting behavior across every frame
2. The cadence quarantine restated, if strobe is present
3. Who is in the sequence and who is not
4. Staging locks — who stays where, who never stands, who never enters
5. Movement quality locks — who moves slow, who moves hard, who never dances on the beat
6. Permanent identity features restated (bangs, masks, mic hand)
7. Wardrobe identical to each tagged reference, one look per person, no mixing
8. Environment identical across all shots
9. Every shot a different angle and height
10. Camera handheld throughout, frame never settles square
11. Atmosphere holds uniform, never drifts, never reads as fog
12. Skin protection
13. Performance negations — no mouthed words, no singing, no teeth
---
## BLOCK 11 — LAST FRAME
The exact final composition. Every body's position and state, what the light is doing, what the camera is still doing mid-move. A freeze-frame description.
**No text suppression here.** That lives in its own mandatory directive block at the top. Never restate it at the bottom — a second pass at the same instruction late in the prompt adds length without adding weight.
Last Frame describes composition only. Nothing else.
---
## BLOCK 12 — SOUND BED
**Default: diegetic only.** Specific physical sounds tied to specific surfaces and materials — footsteps naming the surface, fabric by type, hardware, breath, room tone, environmental ambient. Close with *"No music, no lyrics, no dialogue, no singing."*
Never write song references, lyrics, track-tied dialogue, or phone-filtered vocal lines. Music is uploaded separately as an audio reference.
**Lipsync exception — HARD LOCK.** When an audio or video track is attached, it is the sole and complete audio source:
```
Sound Bed: The attached clip @[Video 1](video_1) is the sole and complete audio source for this sequence. Generate no additional audio of any kind — no room tone, no foley, no ambience, no breath, no added dialogue, no music.
```
The attached clip also owns all internal timing. Never impose per-beat timing on a lipsync take.
**Spoken dialogue is allowed** when a scene has real speech. Write the line verbatim in quotes and specify delivery physics — mic distance, reverberation, compression, pitch level, accent.
**Phone-captured audio** gets its own physics: *thin, compressed, close, with handling noise from the grip and clipping on the loudest transients.*
---
## BLOCK 13 — CAMERA & CAPTURE REALISM (MERGED CLOSER)
One block, always last. Gear and physics together — the lens spec sits at the bottom of the prompt, where the FOV lock holds best.
Six parts, in order:
**1. Capture register and lens.** Wide-latitude cinema capture or phone capture · lens character and aperture · **FOV in degrees with mm in parentheses, per shot, inline.**
**2. Camera physicality.** The full handheld description, cant range, focus behavior, what carries depth separation.
**3. Rendition.** Film stock and grain, or phone processing. Frame rate, shutter, exposure, total runtime.
**4. Grade.** Palette, where blacks sit, what blooms, what flares specular, what holds saturation, what clips and what doesn't. Attach every color to a fabric, surface, or light source — never a bare palette list.
**5. Skin.** Locked language:
> Skin reads true cinematic matte — zero shine on forehead, nose bridge, cheekbones, and collarbones, real fine even pore texture, real peach fuzz at the jaw and hairline, real lip surface texture, light absorbed like true subsurface scattering, warm fair skin protected and rendering true and natural, never plastic, never doll-skin, never harsh — no acne, no blemishes, no enlarged or rough pores, fine flattering texture that keeps every face looking good.
**The flattering ceiling is locked.** Realism never makes a face look ugly. Where matte-realism and flattering conflict, resolve toward flattering.
**6. Material run and negation tail.** Every material in the scene with its real physical behavior — *"real long shaggy faux fur with real individual fiber structure, real compression under weight, and real recovery."* Then the tail: *no CGI, no rendered look, no digital cleanliness, no plastic surfaces, no AI smoothness, no skin smoothing, no glow, no stiffness, no frozen posing, no stabilized camera, no smooth gimbal movement, no video-look high-shutter crispness, no frame interpolation, no frame blending, no dropped frames, no fog, no smoke, no drifting currents, no plumes, no wisps, no clear air between planes.*
Tune the tail to the scene. Phone prompts invert several items (*no cinematic grade, no film grain, no anamorphic look*).
**Never state aspect ratio.** Aspect is set in the platform UI.
---
## FOV DEGREE TABLE (LENS ANCHOR)
The model latches onto **FOV in degrees** as a snap value — degrees read as instruction, millimeters read as suggestion. Write the degree first with mm in parentheses. Pick from the anchor steps; never write an off-ladder value like 23°.
| FOV | mm equiv | Feel | Use for |
|---|---|---|---|
| 180° | fisheye | spherical bulge | POV, dream-state, hallucination |
| 107° | 1416mm | architectural ultra-wide | vast interior scale, epic establishing |
| 84° | 2024mm | wide | full-body group blocking, environmental establish |
| 63° | 2835mm | reportage wide | observational, walking-alongside, doc feel |
| 47° | 4050mm | eye-level neutral | universal medium, two-shot, waist-up |
| 29° | 7585mm | portrait compression | isolated bust, tight coverage |
| 18° | 100135mm | portrait tight | identity-hold close-up, held emotional beat |
| 12° | 180200mm | tele detail | hand insert, object close, jewelry, texture |
| 8° | 300400mm | extreme long-lens | anchored-far observation, broadcast, watchtower |
Write as: `47° (50mm) eye-level neutral in shot 1, 18° (100mm) portrait tight in shot 2.` Never mm alone. Never an off-ladder degree.
**Extreme-FOV multishot** (8° or 107° across several beats) drifts fastest. Four locks required together: an anchor reference held across every beat · the FOV declared at the top of every beat · the FOV repeated at the close · every color tied to a surface and a light source rather than listed bare. Drop one and it drifts by beat three.
---
## THE LIPSYNC PROTOCOL
Lipsync fails for four diagnosable reasons: the lyric was stated abstractly rather than as a score; the mouth got obscured; too many cuts forced per-shot mouth re-initialization; other CRITICAL blocks out-competed the singing instruction.
Five parts. Use all five.
### 1. Promote the singing to the top block
```
THE SINGING IS THE PRIMARY SUBJECT OF THIS SEQUENCE — every other element is secondary to it. [Description by hair and wardrobe] sings out loud, full voice, mouth open and working hard, for all [X] seconds without stopping. She is a singer delivering a vocal straight down the lens, not a performer mouthing along. Her mouth is the focus of every shot.
```
### 2. Write the lyric verbatim, then the mouth mechanics word by word
Bilabial consonants — **B, M, P** — get maximum emphasis. A visible lip seal is what the eye reads as real lipsync.
```
"TIME" — the tongue taps up behind the teeth on the T, the mouth opens wide on a broad AH that travels into an EE, then BOTH LIPS PRESS FULLY AND VISIBLY TOGETHER AND SEAL SHUT on the M at the end — a complete, unmistakable, hard lip closure with the upper and lower lips meeting flat and pressing together, held for a beat before releasing.
```
Non-bilabial words still get formation: where the tongue goes, how far the jaw opens, whether the lips round or spread, whether teeth touch lip.
### 3. State the closure count
```
THE PATTERN OF CLOSURES: four hard lip seals across the sequence — on the M ending "TIME," the M starting "ME," the B starting "BEEN," and the B starting "BEFORE" — plus a smaller visible closure on the P of "UP." Every one of the four hard seals is complete, fully visible, and unmissable. The mouth is never lazily half-open and never mumbling between them.
```
**Building a closure map:** scan the line for B, M, P — those are the hard seals. F and V are teeth-on-lip, described but not counted. Sustained final vowels are declared held open.
### 4. Lock mouth visibility in its own block
```
HER MOUTH IS ALWAYS VISIBLE AND ALWAYS READABLE — CRITICAL: her face is turned toward the lens and her mouth is unobstructed, frontal, and clearly readable in every single frame of every shot, and it stays readable through the camera movement, through the cant, and through every flicker of the light. Nothing ever covers it — no hand, no hair, no arm, no other body. She never turns her mouth away from the lens.
```
### 5. Hand timing to the clip and minimize cuts
```
Do not invent or impose any internal timing on the singing — the attached clip @[Video 1](video_1) owns the timing entirely, and every syllable, vowel opening, and lip seal syncs precisely to the vocal in that clip.
```
Prefer one continuous take. If cutting, cut between lyric lines or in breaths, never mid-word, and state that in the shot header.
**Strobe fights lipsync.** Hard flash-to-black eats roughly half the closures. When both are wanted, flag it and soften the strobe on the singer only — a fast bright flicker that never drops her face fully to black — while background bodies keep the full treatment. Say so in both THE LIGHT and Cross-Frame Rules.
---
## STROBE GRAMMAR
```
THE STROBE IS THE DEFINING FEATURE OF THIS SEQUENCE — CRITICAL: the space is lit by hard white strobe flashes firing relentlessly on a fast [BPM] beat per minute pulse. The rhythm is flash, black, flash, black — hard on, hard off, with occasional double and triple stutter runs. Each flash is instantaneous and brilliant, revealing the scene crisply frozen mid-motion, hard-edged and contrasty. Each black interval drops the frame to near-total darkness. There is no fade in and no fade out — every transition is a hard snap. Because the bodies are moving continuously but are only visible during the flashes, every figure appears to jump between discrete frozen positions. Nothing ever sits at a comfortable normal exposure at any point.
```
Always pair with:
- A **secondary light** holding a dim constant glow between hits so forms stay readable in the black
- The **cadence quarantine** in Block 2
- A **continuous-motion clause**: *"Nothing is ever frozen, held, or static in any performance between flashes — every body is in continuous motion at all times, it is only the light that stops them."*
**Per-beat light pulsing causes perceived choppiness.** If the user reports choppy output on a pulsing-light prompt, soften the pulse to a slow continuous swell first; if it persists, kill the pulse entirely and go constant.
---
## MOVEMENT AND CHOREOGRAPHY
Name four layers, always, even when a layer is "nothing else moves":
1. **Character motion** — physical actions across the runtime
2. **Micro-motion** — breath, hair, fabric, jewelry, chain swing
3. **Environmental motion** — water, particles, fabric in air
4. **Camera motion** — lives in the closing block, not here
**Synchronized group choreography** needs the unison lock plus the anti-mannequin clause: *"Every dancer hits the same shape at the same moment while carrying her own micro-timing, head angle, and limb height inside the count so the corps never reads as identical mannequins."*
**Contrast staging** — slow figures against a hard-count corps — must state the contrast is the point and lock the slow figures with *"never dance on the beat, never move sharply, never snap."*
**Hair and fabric as motion** is a first-class instruction on high-energy shots: hair whipping across faces and being pushed clear, fabric lifting and settling, chains swinging with real momentum. It reads as physical truth more than any body description.
---
## CAMERA PHYSICALITY LADDER
Four registers, mapping one-to-one onto the dynamic register tiers. Pick one per prompt and hold it.
| Register | Cant range | Language |
|---|---|---|
| **Gentle handheld** | 310° | floating, drifting, riding breath, small organic corrections |
| **Heavy handheld** | 1225° | jolting, bobbing, kicking, lurching, snapping in corrections, high-frequency vibration underneath, framing slipping and hauling back |
| **Violent handheld** | 2545° | punching in and ripping back, hard fast surges, violent corrections, every frame mid-move |
| **Locked-off** | 0° | opt-in only — user must ask for it by name |
**Every register except locked-off closes with:** *"never locked, never stabilized, never mechanically smooth, never gimbal-glide, every frame mid-move, but always smooth and continuous in its own travel."*
That last clause matters. Without it, violent handheld returns broken footage rather than energetic footage.
**Dutch cant is a swinging range in degrees**, plus *"never passing through level, never settling square."*
---
## HOUSE RULES
**No character names anywhere in the prompt body.** Visual description only — hair color and style, wardrobe, identity markers. Applies universally, including staging and positioning.
**No mode line in the prompt.**
**No aspect ratio.** Set in the UI.
**No internal production context.** No "carried through from the previous scene," no "matching the earlier plate." Every prompt is standalone with all details restated fresh.
**No platform or tool names** in the prompt body.
**No meta-commentary.** Every word describes something visible or audible.
**Age-blind.** Describe by role, hair, wardrobe, identity markers.
**English only inside the code block.**
**Brand names, text, and graphics are written verbatim.** If the scene has a Heinz ketchup bottle, write "a Heinz ketchup bottle" and describe the label — the glass shape, the keystone label, the printed lettering. Same for a Nissin Cup Noodles cup, a Coca-Cola can, a specific car model, a wordmark on a tee, a slogan on a slate. Naming the thing renders the thing; a generic paraphrase renders a vague approximation of it. Describe the physical marks alongside the name so the model has something to draw: shape, color, placement, legibility.
**Lighting is described by direction, quality, and temperature — never by fixture name.** No named lamps, no LogC4, no IRE, no stock codes in the body.
---
## STORY BIBLE HANDOFF
If a story bible or canon skill is active in the session, treat it as the identity and context source and this skill as the cinematography grammar. Pull from it:
- **Character voice, movement signature, and stillness register** → into Subject Lock state descriptors and the Subject action lines of each SHOT block
- **Speech patterns and cadence** → into Sound Bed when a scene has real dialogue
- **Aesthetic era, palette, and world texture** → into the grade portion of the closing Camera & Capture Realism block
- **Production rules and locked conventions** → layered on top of House Rules, taking precedence where they conflict
The bible answers *who and what world*. This skill answers *how it is shot*. Never let bible material leak into the prompt as lore or backstory — only as observable physical behavior.
Operate standalone when no bible is present.
---
## PRE-DELIVERY PASS
- [ ] Bolded title with runtime, then a numbered reference list, then one code block
- [ ] Every named character has its own reference slot and its own Subject Lock
- [ ] Group wardrobe sheet has a slot separate from any formation plate
- [ ] Shot header: count, total, every timecode, cut policy, speed policy — timings sum, total ≤ 15s
- [ ] Capture cadence or capture format block sits second
- [ ] Strobe scenes carry the cadence quarantine
- [ ] Dynamic register deduced or asked, and cut rate, cant range, and camera physicality all match it
- [ ] No mode line anywhere in the prompt
- [ ] NO ON-SCREEN TEXT block present, first directive block, no carve-out or exception clause inside it
- [ ] Remaining directive blocks ordered by importance, capped at ~six
- [ ] Subject Locks 90140 words each, wardrobe economical but complete
- [ ] Permanent identity features declared permanent, repeated in Cross-Frame Rules
- [ ] Clean-face negations present
- [ ] No character names anywhere
- [ ] Atmosphere block carries the complete negation battery, planes named
- [ ] Every shot block has all four labeled parts, every shot a different angle and height
- [ ] Cross-Frame Rules covers all thirteen standard items, one line each, nothing restated from a directive block
- [ ] Last Frame is composition only — no text suppression, no repeated locks
- [ ] No fact appears in two blocks — wardrobe, lighting, and atmosphere each live in exactly one place
- [ ] Sound Bed diegetic, or the lipsync sole-source lock if a track is attached
- [ ] Closing block merges camera and realism, carries FOV in degrees with mm, no aspect ratio
- [ ] Lipsync prompts carry all five protocol parts and a closure count
- [ ] Phone prompts explicitly kill the film grammar
- [ ] One fenced code block, no prose inside it
**Repair pass:**
- Wardrobe drifting → restate every garment, not just the changed one
- Choppy output → check cadence block position, then soften or kill any per-beat light pulse
- Lipsync closures missing → check mouth-visibility block, cut count, and whether strobe is eating the face
- Bodies drifting between shots → tighten Cross-Frame Rules staging locks
- Haze reading as fog → the negation battery is incomplete
- Extras appearing → add the population lock as its own directive block
- Slow motion appearing unbidden → add the explicit no-speed-change line to the shot header
- Phone footage reading cinematic → the film-grammar kill line is missing
- Captions or subtitles appearing → the text block drifted down the prompt, or an "other than" carve-out crept back into it
- Prompt long but vague → something is stated twice; find the duplicated fact and delete the later copy
- Prompt overloaded or over 15s → split into two prompts by camera or by beat

View File

@@ -0,0 +1,200 @@
---
name: story-bible-builder
description: "Interview-driven skill that helps AI filmmakers, worldbuilders, and storytellers turn their story into a single dense canon document — the story's bible. Output is a ready-to-install SKILL.md the user drops into Claude as their own custom skill, so every future prompt (image, video, music, dialogue, scene) automatically knows the world, characters, voices, tone, plot, and production rules without eating into the user's 30 memory slots. Covers premise, thesis, timeline, factions, locations, characters with voice and movement locks, ensemble dynamics, plot engines, and production rules. Use whenever a user wants to build a story bible, lock in character consistency, capture their world, define character personality and voice, stop prompt drift, or turn a loose idea into an installable canon skill. Trigger on 'build a bible', 'story bible', 'lock my characters', 'character bible', 'canon doc', 'turn my story into a skill', or any close variant."
---
# Story Bible Builder
An interview-driven skill for building a **single dense canon document** — a story's bible — that ships as an installable SKILL.md the user can drop into Claude as their own custom skill.
The point: instead of burning memory slots on world context, or re-explaining the story every chat, the user gets one file that lives as a skill and auto-loads every time they work on their world.
The output is a locked, opinionated, prompt-ready canon doc. Not a template. Not a workbook. A **bible**.
---
## WHAT THIS SKILL IS FOR
Users bring a story world they're building — a film, a series, a game, an album, an AI-generated universe. This skill interviews them across every dimension of that world and assembles it into a single `SKILL.md` file matching the structure below. They install that file as a skill. Future Claude sessions read it and know their world.
**The output is one file.** Not modular. Not multi-file. One dense, canon SKILL.md — because that's what installs cleanly as a skill and stays under 500 lines.
---
## TWO WAYS THE BIBLE GETS USED
Every bible this skill produces is engineered to work in **both** modes below. Design every output to serve both.
### Mode 1 — Standalone canon reference
The user installs the bible as a skill and uses it on its own. Future Claude sessions read the bible when the user asks for anything set in that world — a scene, a dialogue exchange, a character beat, an outfit, a song lyric, a straight text-to-image prompt, a story pitch, a treatment. The bible provides the context so Claude doesn't have to ask.
For this mode, the bible needs to be **dense, opinionated, and self-contained** — no dependencies on other skills.
### Mode 2 — Context source for a video prompt director skill
Many users pair the bible with a video prompt director skill (like `cinema-worldbuilder-pro-20` or similar). In this pairing, the director skill handles the cinematography grammar, mode selection, frame composition, and prompt syntax. The bible provides the identity, voice, movement, aesthetic era locks, and canon that the director skill can't get from a reference image alone.
The director skill reads uploaded reference images for wardrobe, hair, and identity. It cannot read *voice*, *movement quality*, *stillness*, *what era's aesthetic applies*, or *what production rules are locked for this world*. Those come from the bible.
**When both skills are active in the same session**, the director skill should pull directly from the bible for:
- **Character voice descriptors** (goes into Sound Bed / dialogue direction)
- **Character movement and stillness descriptors** (goes into Subject Lock block)
- **Aesthetic era differentiation** (goes into World Plate / grade selection)
- **Production rules** (locked visual traits that must appear in every render — piercings, scars, permanent hair features, "never" clauses)
- **Ensemble dynamics** (informs Cross-Frame Rules when multiple canonical characters share a shot)
The bible's job is to make every one of those descriptors **copy-paste-ready** — quoted, tight, prompt-facing. If a descriptor in the bible can't be pasted verbatim into a Seedance Sound Bed or Subject Lock block, it's written wrong.
---
## THE STRUCTURE OF THE OUTPUT
Every bible this skill produces follows this section order. Not every section applies to every story — skip cleanly when the user says something doesn't apply.
1. **One-line premise** — the whole story in a sentence
2. **The thesis** — the core question every scene comes back to
3. **The world — timeline** — eras, years, what defines each
4. **Aesthetic era differentiation** — the palette/lighting/texture per era (critical for prompts)
5. **Major factions / powers / entities** — antagonists, protagonist groups, third parties
6. **Bases / locations** — where things happen, with visual specificity
7. **The rules of the world** — technology, magic, social systems, what's normal, what's forbidden
8. **The characters** — one deep section per character (structure below)
9. **Relationships and ensemble dynamics** — who fills silence, who leads what, who watches
10. **Structural engines** — the story shapes chapters can take
11. **Production rules** — locked prompt-facing rules the user has learned about their own work
12. **When this skill is active** — instructions to future Claude on how to use the bible, in both standalone and paired-with-director modes
Reference `references/character-section-format.md` for how each character section is structured. Reference `references/example-bible-excerpts.md` when the user needs to see what a good section looks like.
---
## THE BUILD FLOW
### Step 0 — Scope check (fast)
Ask in one compact block:
- What's the working title of the story?
- How many main characters?
- What genre/vibe? (One or two references — films, shows, games, albums, aesthetic movements)
- Does the world already exist in their head, or are we building parts from scratch?
- Which prompt tools will future scenes use? (Seedance, Banana Pro, Midjourney, Suno, ElevenLabs, etc.)
This shapes pacing, section depth, and what production rules to bake in at the end.
### Step 1 — The spine (premise, thesis, world timeline, aesthetic)
Sections 14 of the output. Interview the user through:
- **One-line premise.** Push hard for one sentence. If they give more, help compress. The compression is the value.
- **Thesis.** Not plot. Theme. The question every scene answers. If they can't state it, help them find it by asking "what's your character actually deciding, every time?"
- **Timeline.** Walk era by era. For each, ask: year(s), what defines it, aesthetic differentiation (palette, lighting quality, texture, grain). The aesthetic-per-era block is what keeps future image prompts consistent — push for specificity.
Assistant behavior: never invent. Mark `[TBD]` if the user doesn't know. Push on aesthetic locks — this is where most bibles are weakest. When a user says "dark and moody," ask "what colors specifically?" Reference the demo excerpts in `references/example-bible-excerpts.md` if the user needs to see the level of specificity.
### Step 2 — Factions, locations, world rules (sections 57)
Cover these together — they interlock.
- **Factions.** For each: name, what they do, what they believe, how they read visually (uniform, silhouette, color signature), public face vs actual behavior if there's a gap.
- **Locations.** For each: name, what it is, visual tags (three to seven words), function in the story. Push for the visual — "Berlin bunker" is not enough. Concrete walls, monitor light, cable spaghetti, one warm amber pocket — that's a location.
- **World rules.** Ask about: technology tier and access, magic/powers/abilities (and how they LOOK on screen — color, motion, sound), social systems, what's normal here that isn't normal in our world, what's forbidden or dangerous.
Short declarative bullets, not paragraphs. Dense.
### Step 3 — The characters (section 8, the biggest section)
**One character at a time. Never batch.** Each character gets a deep dedicated pass. This is where most of the bible lives.
For each character, run the character interview in `references/character-interview.md`. It covers:
- **Visual lock** — the physical descriptors that must appear in every prompt
- **Function in the story** — what role they play in the overall narrative
- **Backstory beats** — where they came from, what shaped them
- **Present-tense psychology** — where they are RIGHT NOW in the story, what they're carrying
- **Speech pattern** — register, cadence, vocabulary, signature phrases, dialogue prompt-ready descriptor
- **Movement pattern** — how they move, combat if relevant, gestures, tics
- **Stillness pattern** — what they do when they're not moving (often more revealing than movement)
- **Musical voice if music is in scope** — Suno-ready descriptor
After each character is drafted, show the user their section and ask: "Add anything, cut anything, sharpen anything?" Iterate until they lock it. Then move on.
### Step 4 — Relationships and ensemble dynamics (section 9)
Ask: "When these characters are in a room together, what's the shape of the room? Who leads what? Who fills silence? Who watches? Which pairings are calm, which are charged?"
Capture as short declarative sentences. Example: *"Maren and Wren are the quietest room in the house — neither fills space, both notice everything. Owen and Iris cannot be in the same kitchen without one of them leaving."*
This section is short but disproportionately valuable — it's what makes ensemble scenes feel real.
### Step 5 — Structural engines (section 10)
Ask: "What are the recurring chapter shapes your story runs on? Not specific episodes — the shapes. A heist? A rescue? A performance? A confrontation? A flashback?"
List each engine with a one-line description. Note that engines stack (a heist can end in a confrontation, a performance can be interrupted by a rescue). This gives future Claude a menu of story shapes to pull from when the user asks for a new scene.
### Step 6 — Production rules (section 11)
Ask: "What are the rules you've hard-earned about your own work? The stuff that only works in a specific way. Prompt rules, naming rules, canon lock rules, aesthetic rules that can't be broken."
Bake in these defaults for AI-filmmaker users unless the user overrides:
- No character names in image/video/music prompts (models drift on names) — refer by visual description
- Every prompt is standalone (no "matching the previous scene")
- Output prompts in code blocks, no aspect ratio in the prompt body
- Any locked physical traits get restated verbatim in every prompt
Add anything the user has learned from their own prompt work. Copy their exact phrasing where possible — this is a doc of their rules.
### Step 7 — Assembly and "when this skill is active" (section 12)
Assistant now assembles the full SKILL.md. The final section — "When this skill is active" — must instruct future Claude how to use the bible in **both modes**:
**Standalone mode:**
1. When the user asks for anything in this world (scene, dialogue, character beat, outfit, lyric, prompt, treatment), pull relevant character/world context from the bible and use it
2. Stay inside canonical world (year, locations, rules, relationships)
3. Never invent details that conflict with locked canon — ask instead
4. Use the quoted Speech/Movement/Stillness descriptors verbatim when writing prompts
**Paired-with-director-skill mode:**
1. If a video prompt director skill is also active in the session (e.g., `cinema-worldbuilder-pro-20` or similar), the director skill handles cinematography, mode selection, frame composition, and prompt syntax
2. The bible feeds the director skill: character voice → Sound Bed; movement/stillness → Subject Lock; aesthetic era locks → World Plate / grade; production rules → cross-frame rules and locked traits
3. When the user asks for a video prompt in this world, pull the relevant character's voice, movement, and stillness lines and the correct aesthetic era block, and hand them to the director skill's prompt structure
4. Named canonical character references (uploaded reference images the user always attaches) get called out here so the director skill knows to expect them
Ask the user what production companion skills they use (if any) and name them explicitly in this closing section so future Claude knows the paired workflow.
Ship the file with the YAML frontmatter (name + pushy description) and the full canon body. Save to `/mnt/user-data/outputs/[working-title-slug].md` and present.
Offer to zip it as an installable `.skill` file if the user wants that packaging.
---
## HOUSE PRINCIPLES (HOLD THROUGH EVERY INTERVIEW)
1. **Density over prose.** Bibles are working references, not pitch decks. Short declarative sentences. Bold labels. Bullet points. The user should be able to grep this doc.
2. **Never invent.** If the user doesn't know something, mark `[TBD]` in the doc and move on. Invented canon becomes locked canon becomes prompt drift.
3. **Push on the vague.** "Dark and moody" isn't an aesthetic lock. "Cool voice" isn't a voice profile. "Powerful" isn't a description. When the user gives a vague answer, ask the follow-up that makes it prompt-actionable — color specifics, register, movement quality. Reference `references/example-bible-excerpts.md` if the user needs to see the level of specificity.
4. **Locks exclude as much as they include.** When the user locks a physical trait, ask what the wrong-answer drift would be. "Warm fair skin — never pale porcelain, never tan." The "never" clause is what stops model drift over hundreds of future renders.
5. **Character depth matters more than character count.** Better to ship a bible with three deep characters than eight shallow ones. If the user is trying to cram too many characters into the interview, offer to do the deepest three first and hold the rest for a follow-up pass.
6. **The user's voice, not yours.** When they phrase something well, keep their phrasing verbatim. When they name their own rules, quote them. This is their bible.
---
## REFERENCE FILES
- `references/character-section-format.md` — Structure for each character section in the final output
- `references/character-interview.md` — Questions to walk each character through
- `references/example-bible-excerpts.md` — Sample sections at the right density level to reference or show the user
Load each reference only when its step is active.
---
## WHEN THE USER ONLY WANTS PART OF THIS
Common. If they say "I just want to nail down the characters" — skip straight to Step 3. If they want to update an existing bible, ask them to paste it and work from there. The full flow is the default, not a requirement.

View File

@@ -0,0 +1,137 @@
# Character Interview
One character at a time. Run through these in order. After each character is drafted, show the section back to the user and iterate until locked. Then move on.
The output is one character section in the format defined in `character-section-format.md`.
---
## 1. Function
Start here, not with looks. Ask:
**"What role does this character play in the story? Not their job — their *function*. What do they represent? What are they the emotional/narrative anchor for?"**
Look for a role tag — one to three words. *The Heart. The Watcher. The Chaos. The Grounding.* This becomes the italicized tag after the character name in the header.
Push if they give something generic. "The leader" is a job. "The one who feels most for the humans they're saving" is a function.
---
## 2. Visual lock
Cover in this order:
- **Hair.** Color (specific — "cool ash platinum" not "blonde"), length, cut, permanent features (always bangs, always center part, wispy temple pieces)
- **Skin.** Tone with specifier ("warm fair," "cool fair," "olive medium," "warm deep") + finish (dewy, matte, freckled, natural). Ask what the wrong drift would be — that becomes the never-clause.
- **Face structure.** Jaw, cheekbones, chin, nose, lips — one adjective each. Doll-coded, sharp-sculpted, soft, angular, refined. Push for a committed shape.
- **Eyes.** Shape, size, color, any lift at the outer corner
- **Signature identity markers.** The permanent stuff. Piercings, scars, moles, unique features. Ask what would make someone identify them in a crowd. Every character needs at least one.
- **Body / posture.** Height range, build, default stance
- **Default expression.** Watchful? Warm? Blank? Slight smirk? What does their face do when nothing is happening?
- **Locked "never" clauses.** No beauty mark. No teeth-showing smile. No visible piercings. Whatever the wrong-answer drifts are for this character.
The Visual block gets compressed into one dense line for the final output — but interview them on all these pieces to get it right.
---
## 3. Backstory
Ask: **"Give me the origin and the shaping events. Where did they come from, what made them who they are, what happened before the story starts."**
Keep it brief in the final output — one short paragraph or a few tight bullets. The point isn't to write their biography. The point is to give future Claude enough backstory to make consistent choices in scenes.
Push for **one formative detail** that's specific and small. Not "she had a hard childhood" — "she used to fix the sink in their apartment herself at 3am because the plumber would have asked questions." Small specific detail beats grand backstory summary every time.
---
## 4. Present-tense psychology
Ask: **"Where is this character *right now* as the story begins? What are they carrying? What are they about to have to face? What do they not yet know?"**
This section is what makes the bible feel alive. It tells future Claude where the character is emotionally so scenes stay anchored in the right beat.
Push for the internal contradiction — the thing the character is doing versus the thing they're actually feeling. That gap is where the drama lives.
---
## 5. Speech
Ask separately and assemble into one quoted line:
- **Register:** low, mid, high — chesty, throaty, resonant
- **Texture:** clean, dry, warm, breathy, silvery, gravelly
- **Cadence:** fast, slow, deliberate, syncopated, clipped
- **Volume default:** quiet, medium, loud
- **Vocabulary tier:** plainspoken, precise, slangy, poetic, technical, coded
- **Signature moves:** long pauses before speaking, run-on sentences, self-interruption, filler tics, code-switching
Then write it up as one quoted string. Format:
> *"cool mid-register voice with clean smooth tone, measured volume, surgical sentence construction, long deliberate pauses, sophisticated vocabulary, never raises her voice in anger"*
That's the format. Copy-pasteable into Seedance dialogue prompts and ElevenLabs voice casts.
---
## 6. Movement
Ask separately, assemble into one quoted line:
- **Gesture quality:** economical, kinetic, expressive, minimal, weighted, precise, animated
- **Body carriage:** grounded, aesthetic, centered, poised, contained
- **Combat posture (if relevant):** offensive precision, defensive efficient, reactive counter-punch, wildcard creative
- **Habitual tics:** hand at the back of the neck, hands in pockets, self-adjustment, light physical contact with teammates
- **Gait:** heavy-heeled, silent, prowling, floating, deliberate tempo
Assemble one quoted line:
> *"minimal contained gestures, small precise head tilts, reactive counter-punch combat posture, prolonged steady looking, infrequent blinking"*
---
## 7. Stillness
Often more revealing than movement. Ask: **"What do they look like at rest? When they're not talking, not moving, not doing anything? What do their hands do? Where's their weight? What's their expression? Their breath?"**
Assemble one quoted line:
> *"deep settled stillness, square shoulders, hands crossed at the waist or one hand at the opposite elbow, neutral observing expression, slow breath"*
If the character is never really still (high-energy characters), capture that too:
> *"brief tense stillness, animated stance, weight shifted onto one hip with hand on it, gestural opposite hand, closed-lip almost-smirk expression, chin down eyes up"*
---
## 8. Musical voice (music scope only)
Only if the story involves music (AI-idol projects, musicals, music-driven narratives). Ask:
- Vocal range and register in song
- Timbre when singing (often different from speaking)
- Signature register on stage (belt, whisper-sing, rap, half-rap, head voice)
- Style affinity (pop, R&B, hyperpop, alt, ballad, EDM)
- Signature move (breath control on runs, a specific ad-lib style, a rap flow character)
Assemble a Suno-ready descriptor:
> *"high-register clean-cool lead vocalist with steely precision, strong head voice, restrained vibrato, emphasis on control over warmth"*
Never use artist names or song references in Suno prompts — that gets rejected. Describe sonic qualities only.
---
## 9. Sub-beats (optional)
Ask: **"Any specific locked story threads for this character? A hidden relationship, a secret, a recurring pattern, a running joke, a private ritual?"**
Only include as bold sub-beats in the final section if the user says yes and can name it specifically. Don't force this. Sub-beats are for locked canon threads, not speculation.
Example — a locked hidden thread for a returning-sibling character:
> *"The signing: When the lawyer slides the paper across the table, her hand moves before her face does. She signs. She does not look up. She knows before her siblings know that the document is not what they were told it is."*
---
## Iterate
After drafting the character section, show it back:
> "Here's [character name]'s section. Anything to add, cut, or sharpen? Once you approve, this is locked canon in your bible."
Iterate until they lock it. Then move to the next character.

View File

@@ -0,0 +1,55 @@
# Character Section Format
How each character appears in the final assembled bible. Match this shape.
---
## Structure per character
```markdown
### [NAME] — *[Role tag]*
**Visual:** [Hair] [skin] [signature identity markers — piercings, scars, permanent physical features]. [Face structure — one line]. [Body / posture default]. [Default expression]. [Any locked "never" clauses — no beauty mark, no teeth-showing smile, etc.]
**Function in the story:** [What they represent, what role they play, what they were built/born to be, what they became]
**Backstory:** [Origin, formative period, key pre-story events. One short paragraph or a few bullets.]
**Present-tense psychology:** [Where they are RIGHT NOW as the story begins. What they're carrying. What they don't yet know. What they're about to face.]
**Speech:** "[prompt-ready dialogue descriptor — register, texture, cadence, vocabulary, volume]"
**Movement:** "[prompt-ready movement descriptor — gesture quality, combat posture if relevant, tics, gait]"
**Stillness:** "[prompt-ready stillness descriptor — what they do at rest, hands, weight, expression, breath]"
**Suno (if music scope):** "[prompt-ready singing voice descriptor — register, timbre, style, signature move]"
[Optional bold sub-beats for specific narrative threads — e.g., a hidden relationship, a secret, a recurring pattern. Use sparingly and only when locked.]
```
---
## Rules
1. **The four prompt-ready descriptors (Speech, Movement, Stillness, Suno) must be in quotes.** They're engineered to drop verbatim into future prompts — either as standalone prompt inputs, or as feeder blocks for a video prompt director skill (like `cinema-worldbuilder-pro-20`). Specifically:
- **Speech** feeds the Sound Bed block or dialogue direction
- **Movement** and **Stillness** feed the Subject Lock block
- **Suno** feeds vocal casting for music prompts
That's the whole point — the descriptors are formatted this way so they slot in cleanly.
2. **Never use the character's name inside the quoted descriptors.** Refer by trait ("low-register voice," "cocked-hip stance"). Names drift models. Names live in the header only.
3. **Visual block is one dense line.** Not a bulleted list of features. One flowing sentence-fragment block with commas and periods. Easier to scan, easier to paste into a prompt as a character anchor.
4. **Function comes before backstory.** What they ARE in the story matters more than where they came from. Function is what future Claude uses to write scenes. Backstory is context.
5. **Present-tense psychology is what makes the bible alive.** It tells future Claude where the character is emotionally at the current story moment, so scenes are anchored in the right beat.
6. **Bold sub-beats are optional.** Use them for locked narrative threads (a hidden relationship, a secret, a recurring pattern). Don't force them. If a character's arc is straightforward, skip.
---
## Reference the demo bible
The example excerpts reference file (`example-bible-excerpts.md`) contains a full character section (MAREN from the HOLLOWTIDE demo) in this exact format. If the user has never seen one, show them that section as a reference. That's the density and shape to hit.

View File

@@ -0,0 +1,115 @@
# Example Bible Excerpts
Show these to the user when their answers are too vague, or when they ask "what should this look like." These are density-and-shape references — the level of specificity the bible needs to hit.
All examples come from a fictional demo bible (working title: **HOLLOWTIDE**, a folk-horror ensemble set in a coastal fishing town). It's invented purely for calibration. Use it to show the format, then pivot back to the user's own story.
---
## Example — One-line premise
> *"In a fog-locked fishing town where the tide brings something back every full moon, four estranged siblings return home for their mother's funeral and find out she was the last thing keeping it at bay."*
Not one sentence exactly, but the shape is right — setup, protagonist role, engine, twist.
---
## Example — Thesis
> *"HOLLOWTIDE is a story about what a family owes a place, and what a place owes a family. Every chapter is some version of one question: **What do you inherit that you never agreed to?**"*
Thesis works when it collapses to a single question every scene answers.
---
## Example — Aesthetic era differentiation
Notice how each era gets a distinct visual signature, not just a mood:
> *"- **1962 (flashback era)** — grainy Kodachrome, warm ambers, salt-stained wood, kerosene lamplight, low sun through fog*
> *- **Present day (main timeline)** — desaturated grays and mossy greens, wet stone, sodium streetlight cutting through mist, long lens compression*
> *- **The Tideward (the thing beneath)** — inverted palette, bioluminescent teal against total black, wet reflective surfaces, no natural light ever"*
Each line is copy-paste-ready as the environmental anchor for a scene prompt in that era.
---
## Example — Faction
> *"**The Harbor Council.** The seven oldest families of the town, meeting monthly in the salt-house on the pier. They own the docks, the cannery, the ferry, and every business that touches the water. They present as civic stewards — parades, memorials, scholarships. Underneath: they enforce the tide-pacts. Every generation, one member of one family goes into the water on the last night of the year. The town has voted in that death since 1798. No one outside the seven families knows. Fighting the Harbor Council means fighting the town's memory of itself."*
Faction blocks work when they cover: what they own or do, what they believe, how they present publicly, the gap between public and actual, and why they're hard to fight.
---
## Example — Location
> *"**The salt-house.** A century-old wooden structure at the end of the longest pier, built on stilts sunk into bedrock. Interior: raw plank walls, tallow candles in iron holders, a single long table, seven chairs, no electricity. The council meets here monthly. Nothing has been added, removed, or repaired in ninety years — anything that breaks, stays broken. The floor sags. The candles smoke. The building holds."*
Location works when it has: what it is (function), specific visual tags for prompts, and one poetic line that captures the feel ("The building holds.").
---
## Example — Character section
```
### MAREN — *The Eldest*
**Visual:** Ash-blonde hair, cropped short and uneven, like she cut it herself. Cool fair skin, freckled across the nose and cheekbones. Sharp square jaw, straight nose, thin unpainted lips. Gray-green eyes, downturned at the outer corner, deep set. Small silver ring in the left earlobe, worn since 16. Tall, wiry build, hands too big for her wrists. Default expression: watchful, jaw slightly set. No teeth-showing smile.
**Function:** The one who left and stayed gone. Represents refusal — the sibling who tried to break inheritance by walking away. Learns across the story that walking away is not the same as being free.
**Backstory:** Left town at 19, twelve years ago. Works as a paramedic three states over. Has not spoken to her mother in eight years and to her siblings in three. Came home only when the estate lawyer called. Once, at 15, saw something in the water she has never told anyone about.
**Present-tense psychology:** Braced. Wearing the same jacket she left in twelve years ago because she didn't pack for staying. Carrying the certainty that if she doesn't get out by the funeral, she never will. About to be handed a document that requires her signature to leave.
**Speech:** "low mid-register voice with dry contained tone, quiet volume, short precise sentences, deliberate pausing before answering, plainspoken practical vocabulary, never raises her voice"
**Movement:** "economical gestures, weight settled and grounded, walks with a slight limp in the left leg she doesn't acknowledge, keeps her hands in her jacket pockets when idle"
**Stillness:** "settled watchful stillness, weight evenly distributed, hands in pockets, shoulders slightly raised, jaw set, eyes level and unblinking"
**The signing:** When the lawyer slides the paper across the table, her hand moves before her face does. She signs. She does not look up. She knows before her siblings know that the document is not what they were told it is.
```
That's the density. Every section is one to three lines. Every quoted descriptor is prompt-ready. Every backstory beat is specific ("wearing the same jacket she left in twelve years ago because she didn't pack for staying" — not "she doesn't want to be here").
---
## Example — Ensemble dynamics
> *"**Among the siblings:** Maren leads by refusing to. Owen fills every silence with something practical. Iris asks the question everyone else is avoiding. Wren watches the other three and reports back to no one.*
>
> ***Pairings:** Maren and Wren are the quietest room in the house — neither fills space, both notice everything. Owen and Iris cannot be in the same kitchen without one of them leaving. Maren and Owen fight in complete sentences. Wren has not spoken to Owen since 2019."*
Ensemble dynamics work when they name the *feel* of specific pairings and groupings, not just the relationships.
---
## Example — Structural engine
> *"1. **The council meeting** — a Harbor Council session, always in the salt-house, always at night. A vote is taken. Someone loses.*
> *2. **The tide chapter** — a full-moon night. The Tideward comes closest to surface. Rules of the world bend. Combat, if any, lives here.*
> *3. **The confession chapter** — one sibling tells one other sibling one thing. Whole story shifts."*
Engines work when each is a shape (not a plot), and the user can mix and match them for new chapters.
---
## Example — Production rule
> *"**Locked physical traits, never omitted:***
> *- Maren: ash-blonde uneven crop, small silver earring left ear, slight limp*
> *- Owen: always wearing his father's watch, band cracked, still worn*
> *- All four siblings: cool fair freckled skin (never tan, never porcelain)*
> *- All four siblings: no teeth-showing smiles — default expression is watchful, closed-lip"*
Production rules work when they include the "never" clause. What's excluded is as important as what's included.
---
## The universal signal
Every section should pass this test: **could a stranger who has never heard of this story write a scene in it, using only this bible, and get it right?**
If yes, the section is dense enough. If no, keep pushing.

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,546 @@
---
name: character-builder
description: "Character and outfit builder for Higgsfield image generation. Three workflows: (1) BUILD FROM SCRATCH — photoreal humans via a Higgsfield Soul face test followed by a detailed 3:4 chest-up lock in Nano Banana Pro or Higgsfield GPT-2, or anime/cel-shaded characters built directly in Banana Pro with a full cel render stack; (2) ADDITIONS TO AN EXISTING CHARACTER — hair changes, makeup registers, piercings, tattoos, scars, expression sets, and re-locks that extend a character without breaking identity; (3) OUTFIT BUILDER — wardrobe proposals, direct 9:16 builds on the locked character, invisible-mannequin garment plates for complex looks, and 3-panel character sheets. Covers the flat 18% gray plate, the flattering-realism ceiling, the cel-shade render stack, identity-marker locking, and reference reading. Use for building a new character, locking a face, changing a character's hair or markings, designing an outfit, generating a character sheet, or any character reference still."
---
# Character Builder — Higgsfield Character & Outfit Grammar
The locked grammar for building characters and their wardrobes as reusable image assets. A character is not one picture — it is a canonical face lock, a set of identity markers, and a growing library of outfits, all anchored so that every downstream image and video prompt renders the same person.
Three workflows:
| Workflow | Use when |
|---|---|
| **Part 1 — BUILD FROM SCRATCH** | No reference exists. The character is being invented. |
| **Part 2 — ADDITIONS** | The character is locked. Something is being changed or added to them permanently. |
| **Part 3 — OUTFIT BUILDER** | The character is locked. A new fit is being designed and put on them. |
Never skip forward. An outfit cannot be built on a character whose face isn't locked. An addition cannot be made to a character who doesn't exist yet.
---
## CORE PHILOSOPHY
No plastic. No CGI sheen. No 3D-render look. No commercial gloss. No AI-generic skin or hair.
### The two axes — separate these, always
Photoreal character work runs two things that sound like one thing and are not:
**Axis 1 — BIOLOGICAL REALISM: fully on.** The subject must read as a real living person. Real pore texture, real peach fuzz at the jaw and hairline, real subsurface scattering, hair rendered strand by strand with flyaways and baby hairs, real fabric weave and weight and drape, real metal surface on jewelry, real eyes with depth and moisture. This never comes off.
**Axis 2 — PHOTOGRAPHIC CAPTURE BEHAVIOR: off on every character plate.** A character plate is not a photograph. It carries no lighting, no shadow, no lens character, no atmosphere, and no capture artifacts. No key direction, no shadow side, no cast shadow, no contact shadow, no falloff on the background, no light spill, no bokeh, no depth-of-field falloff, no vignette, no flare, no film grain.
The two get tangled constantly because "photorealistic" sounds like it means both. It doesn't. Writing *"photographed on a real camera by a real photographer"* into a character plate switches on Axis 2 along with Axis 1 — and Axis 2 is exactly what poisons a reference. The subject should look like a real person, rendered flat, against nothing.
**Why Axis 2 is off.** These are references, not finished frames. Any lighting information baked into a plate — a cheek triangle, a nose shadow, a contact shadow under the feet, a soft falloff behind the shoulder, a warm bleed on the backdrop — is inherited and amplified by every downstream generation that reads it, and it fights whatever lighting the actual scene wants. The plate carries zero lighting. The scene prompt or the video prompt does all the lighting later.
### The flattering-realism ceiling — LOCKED, every face, every mode
Full skin realism is always on: visible pore texture, peach fuzz, subsurface scattering, hair flyaways, the matte finish that carries the anti-plastic look. But realism never means unflattering. No acne, no blemishes, no prominent spots, no scarring the user didn't ask for, no enlarged or cratered pores, no rough bumpy texture, no aggressive detail that reads clinical. The texture is fine, soft, even, and natural. Matte is the anti-plastic lever; fine-and-even is the flattering lever. Both run together. Where they conflict, resolve toward flattering — a face should always look good.
### Cel-shaded characters
The philosophy inverts. Every image reads as hand-drawn animation production art. Clean deliberate line weight, hard-edged tonal separation, flat color fills, no photographic grain, no lens blur, no airbrush gradients. Axis 1 is off along with Axis 2 — no pore texture, no subsurface scattering, no strand-level hair.
---
## THE FLAT GRAY PLATE (LOCKED DEFAULT FOR ALL CHARACTER WORK)
**18% neutral gray seamless with a completely flat shadowless grade is the locked default** for face locks, character references, outfit plates, character sheets, and prop references. Pure white is the explicit-request exception, used only for a finished standalone still meant to be posted or handed off.
**Why gray.** Pure white and pure black create maximum subject-to-background contrast. Image and video models amplify errors most at high-contrast edges — that's where halo, edge breathing, and contour instability get baked in. A neutral mid-gray ground lowers subject-to-background contrast, giving cleaner edge extraction and far less inherited contrast when the still is read as a reference frame downstream.
**The background stays neutral; the subject does not.** The ground is an even neutral mid-gray, never warm-shifted. But the gray must never cool or neutralize the subject — skin renders at its true natural tone and wardrobe at its true natural color, exactly as under neutral daylight. The relight-from-scratch language and the explicit "warmth preserved and natural, never pale or washed-out or cool-shifted" clause hold this.
**Why flat.** See Axis 2 above. The plate carries zero lighting information so nothing downstream inherits it.
**The background is a field, not a room.** This is the distinction that matters most and the one most often lost. A photographed seamless is a *physical surface* — it takes light, it falls off, it catches spill, it holds the subject's shadow, it has a floor the subject stands on. A character plate background is none of that. It is a flat uniform color field with nothing behind the subject at all: no surface, no floor, no wall, no corner, no seam, no horizon, no plane the subject makes contact with. The subject does not stand on anything and does not stand in front of anything. Nothing the subject does affects the field.
**Zero shadow outside the subject — absolute.** No cast shadow of any kind anywhere in the frame. No contact shadow, no floor shadow, no drop shadow, no ambient occlusion where the body meets the field, no soft darkening behind the shoulders or under the hem, no halo, no edge darkening, no rim of any kind separating the figure from the field. Shading exists only *on* the subject and stops at the subject's silhouette.
**Zero light bleed outside the subject.** No spill, no glow, no bounce, no color cast thrown from the subject or the wardrobe onto the field. No brightening behind the head, no warm bleed off the skin, no reflected color from a bright garment. The field's value is identical at every pixel whether it is beside the subject or in the far corner.
**LOCKED FLAT CLOSE — use verbatim on every photoreal character plate:**
```
The background is a single flat 18% neutral gray field — one uniform value at every pixel corner to corner, identical directly beside the subject and in the far corners, with no seam line, no gradient, no hotspot, no vignette, and no falloff to lighter or darker anywhere in the frame. It is a flat color field, not a photographed backdrop — no surface, no floor, no wall, no corner, no horizon, and no plane the figure stands on or in front of.
Relight from scratch overriding any reference lighting: completely flat shadowless illumination — one enormous soft frontal source at camera position wrapping the subject evenly, matched equal fill from camera-left and camera-right at identical intensity, matched fill from above and below, so both sides of the face read at exactly the same brightness. No key-and-fill ratio, no modelling, no shadow side, no cheek triangle, no nose shadow, no under-chin shadow, no rim light, no hair light, no kicker, no specular hotspot. Extremely low contrast, even, milky, catalogue-flat. Form is described by bone structure, hair strands, and fabric folds alone, not by light and shadow.
Absolutely zero shadow anywhere outside the subject. No cast shadow, no contact shadow, no floor shadow, no drop shadow, no ambient occlusion where the body meets the background, no soft darkening behind the shoulders or beneath the hem, no halo, no edge darkening, and no rim separating the figure from the field. Shading exists only on the subject and stops cleanly at the silhouette. Absolutely zero light bleed outside the subject — no spill, no glow, no bounce, no reflected color cast thrown from the skin or the wardrobe onto the background, and no brightening anywhere behind the figure.
Skin reads matte and velvety — zero shine on forehead, nose bridge, cheekbones, temples, and chin, no oily T-zone. Skin renders at its true natural skin tone and wardrobe at its true natural color, warmth preserved and natural against the neutral gray, never pale or washed-out or cool-shifted by the background. Real peach fuzz at the jaw and hairline, real soft fine even pore texture, subsurface scattering reading as semi-translucent biology, real hair rendered strand by strand with fine flyaways at the hairline, real fabric weave and drape, never plastic, never waxy, never glass-skin, never harsh — fine flattering texture that keeps the face looking good, no acne, no blemishes, no rough pores.
Even sharpness edge to edge across the entire frame. No depth-of-field falloff, no bokeh, no background blur, no lens vignette, no lens distortion, no flare, no bloom, no chromatic aberration, no film grain, no atmospheric haze, no air between the subject and the background.
```
**Four things must appear in every flat close, always:**
1. **Flat field** — one uniform value at every pixel, explicitly a color field rather than a photographed surface
2. **Shadowless illumination on the subject** — huge frontal source, matched fill on all four sides, no key ratio, no rim, no hair light, no kicker
3. **Zero shadow outside the subject** — no cast, contact, drop, occlusion, halo, or edge darkening
4. **Zero light bleed outside the subject** — no spill, glow, bounce, or reflected color onto the field
Miss any one and the plate comes back with lighting information baked into it.
**For sheets:** the flatness must be stated as applying *uniformly across all panels* — same gray value, same shadowless light, no cast shadow in any panel.
---
# PART 1 — BUILD FROM SCRATCH
For any character with no existing reference. Three stages, in order: text spec → style fork → build.
## Stage 1 — The text spec
Let the user describe the character in their own words. Listen, then mirror back a locked spec in plain language covering:
- **Apparent age register** — described by build and bearing, never a number
- **Face** — head shape, bone structure, jaw, chin, cheekbones, brow shape, eye shape and color, nose, lip shape
- **Skin** — tone and finish
- **Hair** — color with every nuance, length, texture, part, styling
- **Body** — build, proportions, posture
- **Default makeup register** — if any
- **Default expression and energy**
- **Identity markers** — piercings with position and metal, scars with placement and size, beauty marks, tattoos, signature jewelry
Iterate the text spec freely until the user says it's locked. Nothing gets generated until they do. Fixing a face in text costs nothing; fixing it after twelve outfits have been built on it costs everything.
## Stage 2 — The style fork
Ask once, before any prompt:
> Photoreal human, or anime / cel-shaded?
The two paths do not share a render register, a tool routing, or a build sequence. Pick one before writing anything.
---
## PATH A — PHOTOREAL HUMAN
Three tools. Ask which before writing anything.
| Tool | Role | When |
|---|---|---|
| **Higgsfield Soul** | Face test pass | Cheap and fast. Throws variations so the user can see the face several ways before committing. |
| **Nano Banana Pro** | Canonical lock — default | Balanced fidelity, reasonable cost, handles any framing. The standard lock tool. |
| **Higgsfield GPT-2** | Canonical lock — maximum fidelity | Sharpest read on micro-detail at face-and-shoulders range. Chest-up only. Higher credit cost. |
**The standard sequence is Soul → Nano Banana Pro.** Run Soul first for the test pass, then lock in Nano Banana Pro. Skip Soul only if the user already knows exactly what the face is.
**GPT-2 substitutes for Nano Banana Pro at the lock step**, not for Soul. It reads pores, lash separation, iris pattern, lip surface, and hair-strand definition at the hairline noticeably sharper than Nano Banana Pro — worth the credits when the face is the entire point of the image, which on a canonical lock it always is.
**Offer GPT-2 whenever a photoreal lock is about to be written**, and whenever the user asks for a highly detailed portrait or headshot on a plain gray field:
> Want the lock on Nano Banana Pro or GPT-2? GPT-2 reads micro-detail sharper — pores, lash separation, iris pattern — but it's chest-up only and costs more credits.
Mention the credit cost **once per conversation**, then drop it.
**GPT-2 constraints:**
- Chest-up framing only. Anything wider loses the fidelity advantage and wastes the credit hit.
- Everything else is identical to the Nano Banana Pro lock — same 3:4 framing, same 17-item description order, same flat plate, same locked close. The tool changes; the grammar does not.
- GPT-2 responds especially well to explicit micro-detail language, so a GPT-2 lock adds one dedicated fidelity paragraph before the flat close (see below).
**Step A1 — Higgsfield Soul face test.** Cheap, fast, loose. Identity essentials only — no fine markers, no makeup detail, no granular anatomy. Soul can't hold those, and asking it to wastes the pass.
**Step A2 — Canonical lock, 3:4 chest-up, in Nano Banana Pro or GPT-2.** Takes the approved Soul plate as the character reference and writes the face out in full detail. This is the image every future prompt anchors to, so it gets the longest, most specific description in the entire skill.
### Step A1 — Soul face test
**Wardrobe lock:** plain black thin-strap camisole for women, plain black ribbed tank for men. No jewelry, no logos, no graphics. Identity-pure.
**Prompt structure — lean, essentials only:**
```
A [heritage] [woman / man] with a [build], [skin tone and finish], [hair color, length, texture]. [Eye shape and color]. [Only large, visually dominant markers — hold fine markers for the lock pass]. [She wears a plain black thin-strap camisole / He wears a plain black ribbed tank], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes to camera, lips closed and relaxed.
The background is a single flat 18% neutral gray field — one uniform value at every pixel corner to corner, no seam line, no gradient, no hotspot, no vignette. It is a flat color field, not a photographed backdrop — no surface, no floor, no wall, no plane the figure stands in front of. Completely flat shadowless illumination — a huge soft frontal source at camera position with matched equal fill from camera-left, camera-right, above, and below, so both sides of the face read at exactly the same brightness. No shadow side, no nose shadow, no under-chin shadow, no rim light, no hair light, no kicker. Absolutely zero shadow outside the subject — no cast shadow, no contact shadow, no drop shadow, no ambient occlusion, no halo, no edge darkening. Absolutely zero light bleed onto the background — no spill, no glow, no bounce, no reflected color cast. Extremely low contrast, even, milky, catalogue-flat. Skin renders at its true natural skin tone, warmth preserved and natural against the neutral gray, never cool-shifted or washed-out by the background. Skin reads matte and slightly diffused, clean and even. Chest-up framing.
Real human skin with visible natural pore texture, fine peach fuzz along the jawline, subtle subsurface scattering on the cheeks and ear edges. Hair rendered strand by strand with realistic natural texture and individual flyaways at the hairline. Even sharpness edge to edge, no depth-of-field falloff, no background blur, no vignette, no flare, no grain.
```
Deliberately lean. No full detail stack, no granular anatomy, no makeup paragraph. Let Soul interpret the face from the essentials so the variations are actually different from each other. The user runs several, picks one, and that becomes the reference for Step A2.
### Step A2 — Canonical 3:4 chest-up lock (Nano Banana Pro or GPT-2)
**This is the most important image in the character's life.** Everything downstream anchors here. Write it long and write it specific.
**Framing:** 3:4 vertical, forehead to upper chest, the face filling most of the frame. A true close-up headshot, not a portrait with air around it. Chest-up, never waist-up — the whole point is resolution on the face.
**What gets written out in full, in this order:**
1. **Framing declaration** — 3:4 chest-up, forehead to upper chest, face filling the frame
2. **Reference anchor** — "the same character as the attached face plate"
3. **Build and heritage** — one clause
4. **Skin** — tone, undertone, finish
5. **Head and face structure** — head shape, forehead, temples, cheekbone height and projection, cheek hollow, jaw angle and definition, chin shape and projection, the line from ear to chin
6. **Eyes** — shape, set, spacing, tilt at the outer corner, lid crease depth and visibility, iris color with its variation across the iris, limbal ring, pupil, the wet line at the inner corner, under-eye structure
7. **Brows** — shape, arch position, thickness, density, hair direction, color relative to the hair
8. **Lashes** — length, density, curl, separation, upper and lower
9. **Nose** — bridge width and straightness, tip shape and projection, nostril shape and visibility, the shadow-free plane transitions
10. **Lips** — fullness upper versus lower, cupid's bow definition, philtrum length and depth, mouth width, corner shape, natural lip color and surface texture
11. **Ears** — shape and set, whether visible under the hair
12. **Hair** — color with every nuance and any tonal variation root to tip, length, texture, part, how it falls, hairline shape, baby hairs
13. **Makeup register** — if any, as a default that swaps freely later
14. **Identity markers** — each with exact placement: piercings by position and metal, beauty marks by location, scars by placement and size, visible tattoos
15. **Wardrobe** — plain black camisole or ribbed tank, no jewelry, no logos
16. **Pose and expression** — squared to camera, head level, neutral relaxed, eyes to camera, lips closed
17. **The LOCKED FLAT CLOSE** — verbatim
**Prompt structure:**
```
A clean cinema-character-reference 3:4 headshot of the same character as the attached face plate, framed from the forehead down to the upper chest with the face filling most of the frame — a true close-up, not a portrait with space around it.
[Build and heritage.] [Skin tone, undertone, and finish.] [Head and face structure — head shape, forehead, temples, cheekbone height and projection, cheek hollow, jaw angle, chin shape, the line from ear to chin.] [Eyes — shape, set, spacing, outer-corner tilt, lid crease, iris color and its variation across the iris, limbal ring, under-eye structure.] [Brows — shape, arch position, thickness, density, direction, color.] [Lashes — length, density, curl, separation, upper and lower.] [Nose — bridge, tip, nostrils.] [Lips — upper versus lower fullness, cupid's bow, philtrum, mouth width, corner shape, natural color and surface texture.] [Ears — shape, set, visibility under the hair.] [Hair — color with every nuance and tonal variation root to tip, length, texture, part, fall, hairline shape, baby hairs.] [Default makeup register, if any.] [Every identity marker with exact placement — piercings by position and metal, beauty marks by location, scars by placement and size, visible tattoos.]
[She wears a plain black thin-strap camisole / He wears a plain black ribbed tank], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes directly to camera, lips closed and relaxed, subtle controlled energy.
[LOCKED FLAT CLOSE — verbatim]
```
The output is the canonical character reference. Every future prompt for this character attaches it.
**GPT-2 addition.** When the lock runs on GPT-2, insert one dedicated fidelity paragraph between the pose line and the flat close. GPT-2 rewards explicit micro-detail language in a way Nano Banana Pro does not:
```
Extreme face fidelity. Real skin texture with visible individual pores, fine peach fuzz catching light along the jawline and upper lip, subtle subsurface scattering across the nose bridge, cheeks, and ear edges reading as semi-translucent biology. Individual lash separation, upper and lower. Real moisture and reflection in the iris with a visible fibrous iris pattern radiating from the pupil and a soft limbal ring at the outer edge. Real lip surface texture with fine natural vertical lip lines. Hair rendered strand by strand at the hairline with visible baby hairs and individual flyaways. Visible fabric weave at the collar and shoulder. Micro-expression detail held in the eye corners and the mouth corners.
```
Everything else in the GPT-2 lock is identical to the Nano Banana Pro lock — same framing, same description order, same flat close, chest-up only.
---
## PATH B — ANIME / CEL-SHADED (single-step, Banana Pro)
Built directly in Banana Pro. No test pass — cel-shaded output is deterministic enough that the detailed prompt gets there in one shot, and a Soul test pass would return a photoreal face that fights the style.
**Go full detail.** Cel-shaded characters live or die on specificity, because the style strips away the incidental realism that would otherwise carry identity. Every simplification is a decision that must be made explicitly.
### The sub-style fork
Ask which register before writing:
| Register | Line | Shading | Eyes | Palette |
|---|---|---|---|---|
| **Modern TV anime** | Clean thin uniform, dark brown-black | 2-tone cel, soft-edged shadow | Large, multi-layer iris, 12 highlights | Bright, moderately saturated |
| **90s cel / retro** | Thicker, slight variation, hard black | 2-tone cel, hard-edged shadow | Rounder, simpler iris, single highlight | Muted, film-print warmth |
| **Manhwa / webtoon** | Very fine, tapering, often colored | 3-tone with soft gradient in hair | Detailed, glossy, heavy lower lash | High-key, pastel, airy |
| **Shonen action** | Bold varying weight, heavy black | 2-tone, hard shadow, strong rim | Sharp, angular, small highlight | Saturated, high contrast |
| **Shojo** | Delicate, tapering, brown-toned | Soft 2-tone, blush-heavy | Very large, deep multi-layer iris, many highlights | Soft, warm, pale |
### What must be specified explicitly
**Line art:** weight and whether it varies · color (pure black, dark brown, or color-matched to the fill it borders) · whether lines close fully or break · line weight on the face versus the body versus the hair.
**Cel shading:** the tone count — two-tone (base plus shadow) is the default; three-tone adds a highlight band · whether shadow edges are hard or slightly softened · where the light comes from, since cel shadows are drawn shapes and need a stated direction even on a flat plate · whether there is any bounce or rim tone at all.
**Color fill:** flat and uniform within each shape · no airbrush gradients · no photographic texture · no noise.
**Eyes — the single most important identity element in cel work.** Specify: overall shape and tilt · iris size relative to the eye opening · the number of iris layers and their colors from outer ring inward · the pupil size and shape · the highlight count, size, and position · whether there is a lower-lid light reflection · upper lash line weight and how far it extends past the outer corner · lower lash presence · the gap between the lash line and the iris top.
**Hair:** rendered in clumped wedges and locks, never individual strands · the number and shape of the front pieces framing the face · the crown shape and how the part reads · the highlight band — its shape, whether it is a single sweep or broken segments, and its color · how the hair overlaps the eyes and brows.
**Face simplification:** how the nose is drawn — a single line, a small wedge, a dot, or a full shape · how the mouth is drawn at rest · whether the ears are drawn in detail or simplified · whether there is a blush and how it is rendered.
**Anti-tells — mandatory negation battery:**
```
No 3D render, no CGI, no photographic texture, no photorealistic skin, no airbrush gradient shading, no soft blurred rendering, no digital painting blend, no lens blur, no depth-of-field falloff, no film grain, no photographic noise, no bloom, no chromatic aberration, no realistic subsurface scattering, no rendered specular highlights on skin.
```
Without that battery the model drifts toward a smooth semi-realistic digital painting that reads as neither anime nor photo.
### Prompt structure — Path B
```
A clean anime character reference illustration, [sub-style register], framed 3:4 from the forehead down to the upper chest with the face filling most of the frame.
[Build and apparent register.] [Skin — flat fill color and undertone.] [Face shape — head silhouette, jaw, chin, cheek line.] [Eyes — shape, tilt, iris size relative to the opening, the iris layers from outer ring inward with their colors, pupil size and shape, highlight count and position, lower-lid reflection, upper lash line weight and extension, lower lash treatment.] [Brows — shape, weight, position relative to the eye, color.] [Nose — exactly how it is drawn.] [Mouth — exactly how it is drawn at rest, width, corner shape.] [Ears — drawn or simplified.] [Blush — present or absent, and how rendered.] [Hair — color as a flat fill plus its shadow tone, the clump and wedge structure, the number and shape of the front framing pieces, the crown and part, the highlight band shape and color, how it overlaps the eyes and brows.] [Identity markers — drawn as deliberate marks with exact placement.]
[Wardrobe — plain black camisole or ribbed tank for an identity-pure plate.] Body squared to camera, head level, neutral expression, eyes to viewer, mouth closed.
Line art: [weight and variation], [color], [closure behavior], [any weight differences between face, body, and hair]. Cel shading: [tone count], [hard or slightly softened edges], light reading from [direction], [rim or bounce tone or none]. All color fills flat and uniform within each shape, no gradients inside a fill.
Background is a single flat [neutral mid-gray / specified color] field, one uniform value at every pixel corner to corner, no gradient, no texture, no vignette. Absolutely zero shadow outside the figure — no cast shadow, no contact shadow, no drop shadow, no ambient occlusion, no halo, no edge darkening — and zero light bleed onto the field.
Hand-drawn animation production art. Clean deliberate line work, hard-edged tonal separation, flat color fills. No 3D render, no CGI, no photographic texture, no photorealistic skin, no airbrush gradient shading, no soft blurred rendering, no digital painting blend, no lens blur, no depth-of-field falloff, no film grain, no photographic noise, no bloom, no chromatic aberration, no realistic subsurface scattering, no rendered specular highlights on skin.
```
---
# PART 2 — ADDITIONS TO AN EXISTING CHARACTER
For a character who already has a canonical reference. Something is being changed or added **permanently** — this is not an outfit, and it is not a one-off scene styling.
## What counts as an addition
| Addition | Notes |
|---|---|
| **Hair color change** | The most common. Full re-lock required. |
| **Hair length or cut change** | Full re-lock required. |
| **New permanent piercing** | Position and metal specified exactly. |
| **New tattoo** | Placement, size, orientation, line weight, and content described in full. |
| **Scar** | Placement, size, age of the scar, raised or flat, color relative to surrounding skin. |
| **Default makeup register change** | Re-lock only if it changes how the face reads at rest. |
| **Expression set** | Adding a second canonical expression to the character's library. |
| **Aging up or down** | Full rebuild, not an addition — go back to Part 1. |
| **Body change** | Full re-lock required. |
## The identity firewall
Every addition prompt must state what is changing **and** state that everything else is held. Without the hold clause, changing hair color drifts the jaw, the eye shape, and the skin tone along with it.
**The hold clause — use verbatim, adjusted for what's changing:**
```
Everything about the character other than [the specific change] is identical to the attached reference and unchanged — the same head shape, the same bone structure, the same jaw and chin, the same cheekbones, the same eye shape and spacing and tilt, the same iris color, the same brow shape, the same nose, the same lips and mouth width, the same ears, the same skin tone and finish, the same build and proportions, and every existing identity marker in the same position. Only [the specific change] is different.
```
## The re-lock rule
**Any addition that changes how the face reads at rest requires a new canonical plate.** Hair color, hair cut, facial piercings, facial scars, and permanent makeup all cross this line. Generate the addition as a fresh 3:4 chest-up lock in the same framing, same wardrobe, same flat plate, and the same style path as the original — and the new plate becomes the character's canonical reference from that point forward.
Additions that don't touch the face at rest — a body tattoo under clothing, a hand tattoo, an ear piercing hidden by hair — can be documented in the character's written spec and written into outfit prompts as needed, without a new face plate.
**Version the plates.** When a character gets a re-lock, both plates continue to exist and refer to different states of the character. Name them so the distinction survives — `[character]-lock-01`, `[character]-lock-02-red-hair`. Always confirm which lock is being anchored to before building an outfit on it.
## Prompt structure — additions
```
A clean [cinema-character-reference / anime character reference] 3:4 headshot of the same character as the attached reference, framed from the forehead down to the upper chest with the face filling most of the frame.
[The change, described in full detail. If it is hair: the new color with every nuance and any tonal variation root to tip, the new length, the new texture, the new part, how it falls, the hairline, baby hairs. If it is a marking: exact placement described relative to a fixed anatomical landmark, exact size, exact orientation, exact color and finish, whether raised or flat, line weight if it is a tattoo.]
[The hold clause — verbatim.]
[Wardrobe — the same plain black camisole or ribbed tank as the original lock], no jewelry, no logos, no graphics. Body squared to camera, head level, neutral relaxed expression, eyes directly to camera, lips closed and relaxed.
[LOCKED FLAT CLOSE for photoreal, or the cel render close for anime — matching the original build path.]
```
## Expression sets
A character can carry more than one canonical expression. Useful for characters who appear across a lot of material with a consistent emotional register.
Build them as a 3-panel sheet in one image so the identity holds across all three: neutral at rest, the character's signature expression, and one extreme. Each panel is the same framing, the same lighting, the same wardrobe — only the face changes.
State per panel exactly which muscles move: brow position, lid aperture, mouth corner direction, jaw, whether teeth show, where the eyes look. Never name the emotion alone. *"Brows drawn slightly together and down at the inner ends, upper lids lowered a fraction, mouth corners level and pressed, jaw set"* renders. *"Angry"* does not.
---
# PART 3 — OUTFIT BUILDER
For a character with a locked face. Three steps. Skipping step 2 is the single most common cause of an outfit rendering wrong.
## Step 1 — The wardrobe proposal (text only)
Before any image, write the outfit out in plain text and wait for approval. Never combine a wardrobe proposal with an image prompt in the same message.
Cover, head to toe:
- **Every garment** — color, fabric, weave or finish, cut, fit, neckline, sleeve, hem position, closures, how it sits on the body
- **Layering** — what goes over what, what's open, what's tucked
- **Structural detail** — cutouts, panels, boning, ruching, pleats, distressing, hardware
- **Footwear** — style, material, heel height and shape, how it interacts with the hem
- **Jewelry** — every piece, metal, scale
- **Accessories** — bags, belts, gloves, eyewear, headwear
- **Nails** — length, shape, finish
- **Hair styling for this outfit** — if it differs from the character's default
- **Makeup for this outfit** — if it differs from the default
Iterate on text until locked. Text iteration is free.
## Step 2 — Build the outfit
Two paths. **Default to the direct build and encourage it.** Only route to the mannequin path when the outfit genuinely earns it.
### Path 1 — DIRECT ON THE MODEL (default, encouraged)
Build the outfit straight onto the locked character in one generation, **9:16 vertical, full body**. The character reference is attached; the prompt writes the wardrobe.
This is the right call for most outfits. It's one generation instead of several, the fit reads on the character's actual proportions from the start, and there's no compositing step to degrade identity.
**Why 9:16.** A full-body outfit reference needs the frame to be taller than it is wide. 9:16 gives the garment the vertical pixel budget — hem lengths, break at the ankle, footwear, and drape all read at usable resolution instead of getting squeezed into the middle of a square.
**Prompt structure — Path 1:**
```
A full-body character reference of the same man as the attached character reference, standing [pose], framed head to toe in a tall vertical 9:16 frame with the full figure and the footwear entirely within the frame.
[Identity restated briefly — build, skin, hair, face register. Two or three clauses only; the reference carries the rest.]
He wears [the full outfit head to toe — every garment with color, fabric, weave or finish, cut, fit, collar or neckline, sleeve, hem position, closures, structural detail, how it sits and moves on the body, layering, footwear, jewelry, accessories, nails].
[Pose and expression.]
[LOCKED FLAT CLOSE — verbatim, adjusted to full-body framing.]
```
### Path 2 — INVISIBLE MANNEQUIN GARMENT PLATES (complex outfits only)
For outfits with multiple custom pieces, unusual construction, heavy hardware, or a specific designed cut that a direct build would round off toward something generic.
**Step 2A — Build each piece in Nano Banana Pro on an invisible mannequin.** One plate per garment, or grouped when pieces read together. The garment holds its full three-dimensional worn shape with no body inside it — collar and cuffs holding their own volume, the opening reading as an empty dark hollow looking down into the inside of the garment, real fabric tension across the chest and shoulders, natural drape and weight, but nothing emerging from any opening.
Because there's no face and no body competing for attention, the entire prompt can be about the garment. This is what makes it hold custom construction that a direct build loses.
**Prompt structure — Step 2A:**
```
A garment reference of a single [garment type] worn on an invisible body, floating in frame and holding its full three-dimensional worn shape.
[The garment in complete detail — color, fabric, weave or finish, cut, fit, collar or neckline construction, sleeve, hem, closures, seams, panels, hardware, pockets, print or pattern with its scale and layout, lining if visible.]
There is no head, no neck, no hands, and no body anywhere in the frame — the garment reads as worn by an invisible figure with full volume, natural drape, and real fabric tension across the chest and shoulders, the collar and cuffs holding their own three-dimensional shape, every opening reading as an empty dark hollow looking down into the inside of the garment with the inner back of the fabric faintly visible. No stump, no skin, no cut edge, no anatomy, no mannequin form, no hanger, no stand, not blurred, not faded, no ghosting, no transparency.
[LOCKED FLAT CLOSE — verbatim.]
```
**Step 2B — Bring the garment plates to Banana Pro with the character.** Attach the character reference plus every garment plate, and compose the full look on the character in one generation.
```
A full-body character reference of the same man as the character reference, wearing the garments from the attached garment references, framed head to toe in a tall vertical 9:16 frame.
Keep every garment exactly as shown in its reference — the same color, the same fabric, the same cut and fit, the same collar and cuffs, the same hem position, the same closures, the same hardware, and the same print or pattern at the same scale. Keep the character's identity exactly as shown — the same face, the same bone structure, the same eye shape and color, the same skin tone, the same hair, and every identity marker in the same position.
[Layering — what goes over what, what is open or closed, what is tucked.] [Footwear, jewelry, and accessories not covered by a garment plate.] [Pose and expression.]
[LOCKED FLAT CLOSE — verbatim, adjusted to full-body framing.]
```
### Choosing the path
**Direct (Path 1)** — a shirt and trousers, a dress, a suit, a jacket over a tee, a matching set, anything the model already understands as a category. Most outfits.
**Mannequin plates (Path 2)** — heavily constructed pieces, unusual silhouettes, custom hardware, garments whose whole point is a specific detail that would get averaged away, or a look where the user wants each piece designed and approved independently before it's committed to the character.
**When in doubt, run Path 1 first.** It's one generation. If the garment comes back rounded off toward generic, that's the signal to fall back to Path 2 for the piece that failed — and only that piece.
## The 3-panel character sheet
Built only after an outfit plate exists on the locked character and the user is happy with it. One image, three vertical panels, one prompt.
### Attach the outfit render alone — not the face lock
**Default: the approved full-look outfit render is the only reference.** It already carries the face, the hair, the skin, the build, the wardrobe, and every accessory in one image, all of them already agreeing with each other. It is a strictly better reference for a sheet than the outfit render plus the face lock, because it needs no reconciliation.
**Adding the canonical face lock alongside it is usually a downgrade.** Two references means two sources for the same face, and the model has to reconcile them — different framing, different crop, different neutral wardrobe, different hair state. That reconciliation costs attention that should be going to the panel geometry and the garment, and on a three-panel sheet the attention budget is already split three ways. Overloading references is one of the most reliable ways to get a mushy sheet.
**Attach the face lock as a second reference only when there's a specific reason:**
- Identity has visibly drifted in the outfit render and needs pulling back toward canon
- The outfit render obscures the face — heavy eyewear, a mask, a hood, deep shadow, a turned head
- The outfit render came out soft or low-detail in the face
- A previous sheet attempt returned a face that doesn't match
- The user explicitly wants the sheet re-anchored to the original lock
When it is attached, say what each reference is for in the prompt body so the model doesn't average them: *"the face, bone structure, and skin tone come from the face reference; the wardrobe, hair styling, and accessories come from the look reference."*
**The same economy applies everywhere in this skill.** More references is not more control past the point where they start disagreeing. Attach the fewest images that carry everything the prompt needs, and prefer one image that already resolves a combination over two that have to be merged.
### Layout
1. **LEFT — full body front, headless.** Full headroom preserved — the head is *removed from the body*, not cropped by the frame edge. Isolates the garment, the silhouette, and the proportions with no facial data competing.
2. **CENTER — full body rear, head attached.** Hair fall, back construction, hem, footwear all readable from behind.
3. **RIGHT — tight chest-up face lock.** Just above the crown down to the collarbones. The face fills the panel. This is the identity anchor and it must be tight.
**The headless cut — pick by garment:**
*Variant A — ghost mannequin.* For structured or closed necklines sitting at or above the collarbone — collars, crew necks, ribbed tanks, turtlenecks, hoods, jacket collars, keyholes. No head and no neck at all; nothing rises above the shoulder line. The collar holds its own three-dimensional shape and the opening reads as an empty dark hollow looking down into the inside of the garment, the inner back of the fabric faintly visible.
*Variant B — clean neck cut.* For garments with no neckline to hollow — strapless, halter, spaghetti strap, deep cowl, scooped or plunging. The neck rises a short way from the shoulders and terminates in a clean flat sharply defined horizontal edge at the base of the throat, like a headless dress-form mannequin.
**Both variants ship the same suppression stack:** not blurred, not faded, not dissolving, no wisps, no smoke, no ghosting, no transparency in the body, no stump, no anatomy detail at the cut, no blood. And the hair goes with the head — no hair falling across the chest or shoulders in the left panel.
**Critical rules:**
- One prompt, one code block, one image. Never three separate prompts.
- **The approved outfit render is normally the only reference.** Add the face lock only for a stated reason, and say what each reference carries when you do.
- Identity and wardrobe described **once** in opening paragraphs, applying to all three panels.
- Each panel describes only what differs — angle, framing, head state.
- **Skin-tone consistency clause is mandatory.** Rear panels drift darker without it: *"skin renders at its true natural skin tone, identical in value and hue across the face, arms, and body in every panel, never darkened, never tanned, never pale or washed-out."*
- Backdrop and lighting uniform across all three cells, stated explicitly as uniform.
- Every panel carries its position label so the grid composes correctly.
---
## READING REFERENCE IMAGES
When the user uploads references, extract everything visible by **visual description only** — never use names, never invent what isn't there.
- **Hair** — color with every nuance, length, style, texture, part, styling treatment, accessories
- **Makeup** — skin finish, coverage register, brow shape and density, eye treatment, lashes, lip, cheek, face jewelry, freckles or beauty marks *only if visible*
- **Wardrobe** — every garment top to bottom: fabric, color, fit, structural detail, neckline, sleeve, hem, layering
- **Jewelry and accessories** — every piece, metal, scale
- **Body markers** — piercings and tattoos *only if visible*, nail length and finish
- **Pose and energy** — body angle, weight, hands, expression register
**No-invention rule.** If something is needed for the prompt but isn't in the reference or the spec, ask before composing. Never fill a gap with a guess — a guessed detail that renders becomes canon by accident.
---
## UNIVERSAL RULES
1. **No character names in prompt output.** Describe by hair, wardrobe, and identity markers. The tools don't know names; visual descriptors survive across prompts.
2. **No aspect ratios in prompt output.** Set in the Higgsfield UI. Describe framing in words — "3:4 chest-up headshot," "full body," "tight close-up."
3. **No `@image` tags or placeholders.** Attachment happens in the UI. The prompt is text-only and refers to references in prose — "the attached face plate," "the character reference," "the outfit reference."
4. **No internal production context.** Every prompt is standalone and self-contained.
5. **Pure visual description only.** No meta-commentary, no explanation of intent, no references to the medium.
6. **No teeth-showing smiles** unless explicitly requested. Default is model face-card neutral, or a slight closed-lip smirk.
7. **Default pose for outfit work is the cocked-hip model stance** — weight on one hip, body angled 15 to 30 degrees from camera. Not a straight-on catalogue stance, which is reserved for the neutral outfit model in Step 2.
8. **Age-blind.** Describe by build, bearing, role, and wardrobe — never by age word or number.
9. **Brand names, text, and graphics are written verbatim.** A specific label, wordmark, slogan, or logo gets named and described — shape, color, placement, legibility. Naming the thing renders the thing.
10. **Flat grade on every character plate and sheet.** Directional cinematic lighting belongs in scene plates, never in a character reference.
11. **Single fenced code block on output.**
---
## DELIVERY FORMAT
Three parts:
**1. Bolded title line.** Names what's being built and which step it is.
`**Face lock — step 2 of 2, Nano Banana Pro 3:4 —**`
**2. Numbered reference list.** One line per attached reference. If none: `No references — text-only build.`
**3. One fenced code block.**
**Tool routing goes in the title**, so the user knows where to paste: Soul, Nano Banana Pro, or Banana Pro.
**On iterations — deliver directly.** Any tweak to an already-approved prompt (palette, framing, pose, lighting, a single garment swap, a styling nudge) ships as the revised full prompt with no confirmation bullets. Re-check only on a full scope change — a new character, a new outfit, a new mode, or a new style path.
**Wardrobe proposals are the exception to deliver-by-default.** A new outfit always gets its text proposal approved before any image prompt is written, and the two never ship in the same message.
---
## PRE-DELIVERY PASS
- [ ] Which part of the skill applies — build from scratch, addition, or outfit — and the prerequisite for that part exists
- [ ] For a new character: text spec locked and approved before any generation
- [ ] Style path forked and the render register matches it throughout
- [ ] Photoreal builds route Soul for the test pass, and Nano Banana Pro or GPT-2 for the lock — with the GPT-2 option offered and its credit cost mentioned once per conversation
- [ ] GPT-2 locks are chest-up only and carry the extreme-fidelity paragraph
- [ ] The lock pass is 3:4 chest-up with the face filling the frame, never waist-up
- [ ] Every facial plane, the eyes in full, and every identity marker with exact placement are written into the lock
- [ ] Anime builds carry line spec, cel tone count, shading direction, full eye construction, hair clump structure, and the anti-tell negation battery
- [ ] Additions carry the hold clause and trigger a re-lock if they change the face at rest
- [ ] Outfits went through the text proposal, then a direct 9:16 build on the locked character — or invisible-mannequin garment plates first if the outfit was complex enough to earn it
- [ ] Character sheets have identity and wardrobe described once, the correct headless variant, and the skin-tone consistency clause
- [ ] Reference economy — the fewest images that carry what the prompt needs, and no canonical face lock stacked onto a sheet without a stated reason
- [ ] Axis 1 on and Axis 2 off — biological realism written in full, zero photographic capture behavior anywhere in the plate
- [ ] Flat gray field, shadowless light on the subject, zero shadow outside the subject, zero light bleed onto the field — stated per panel on sheets
- [ ] No names, no aspect ratios, no placeholder tags, no meta-commentary
- [ ] Bolded title with tool routing, numbered reference list, one code block
**Repair pass:**
- Face drifting between outfits → the lock plate isn't tight enough; rebuild it chest-up with fuller facial description
- Outfit rendering generic or rounded off → fall back to an invisible-mannequin garment plate for the piece that failed, and only that piece
- Rear panel skin darker than front → the skin-tone consistency clause is missing
- Shadow appearing under the feet or behind the shoulder → the zero-shadow-outside-the-subject clause is missing or too short
- Background brightening or picking up color near the figure → the zero-light-bleed clause is missing
- Background reading as a lit wall or floor rather than a flat field → the "color field, not a photographed backdrop" line is missing
- Modelling appearing on the face → one of the four flat requirements is missing
- Grain, vignette, or background blur appearing → capture-behavior language leaked in from Axis 2
- Anime output reading semi-realistic → the anti-tell negation battery is incomplete
- Hair color change also changed the face → the hold clause is missing or too short
- Identity marker in the wrong place → it was described relative to nothing; anchor it to a fixed anatomical landmark
- Sheet coming back mushy or averaged → too many references disagreeing; drop back to the single approved outfit render
- Face on a sheet drifting off canon → this is the case where the face lock earns its slot; attach it and state what each reference carries

Binary file not shown.

View File

@@ -0,0 +1,637 @@
---
name: cinema-director
description: "Cinema director for Seedance and Higgsfield video prompts. Writes production-grade multi-shot prompts in a locked house format — shot/duration header, capture cadence block, ALL-CAPS CRITICAL directive blocks, per-subject Subject Locks with wardrobe restated, Corps/Crew/Prop Locks, World Plate, Atmosphere, timecoded SHOT blocks, Cross-Frame Rules, Last Frame, Sound Bed, and a merged Camera & Capture Realism closer. Covers cinema film capture (24fps, 180-degree shutter, anamorphic, 35mm grain) and phone/BTS capture (30fps, rolling shutter, HDR-flat). Includes the lipsync bilabial closure protocol, strobe grammar, still-haze atmosphere language, an FOV degree lens anchor, skin protection, and diegetic-only audio rules. Use whenever the user wants a Seedance or Higgsfield video prompt, a music video shot, a behind-the-scenes clip, a performance or lipsync sequence, an action or atmospheric sequence, or asks to break a scene into shots for video generation."
---
# Cinema Director — Seedance / Higgsfield Video Prompt Grammar
The working prompt grammar for cinematic AI video. Every prompt is a production document: who is in frame, what they wear, where they stand, what moves, how the camera behaves, what the light does, what the air does, what the audio is, and what the last frame looks like.
The block order, the ALL-CAPS directive convention, the wardrobe-restatement rule, and the negation batteries all exist because their absence caused specific, repeated failures.
---
## CORE PHILOSOPHY
No plastic. No commercial gloss. No LED-panel-on-a-soundstage energy. No Instagram sharpness.
Every frame reads as captured on a real camera operated by a real body. Film-emulated, imperfect, analog warmth in the highlights, blacks that hold detail. Editorial grade, not commercial. Glass with character. Real fabric, real skin, real haze, real grain.
**A great prompt is a production document, not a beautiful sentence.** If a word doesn't produce a visible pixel or an audible sound, cut it.
**Length discipline.** A four-shot sequence with four Subject Locks should land around 1,0001,600 words. Longer than that and the directive blocks start losing weight against the descriptive body. Every line must be a lock, not a flourish.
---
## WRITE THE VISIBLE
The model is a physics engine, not a mood board. It renders things it can see and count. Mood words evaporate.
- ❌ "she looks stressed" → ✅ "shoulders lift, jaw locks, exhales through the nose, eyes fix on the door"
- ❌ "the alley feels dangerous" → ✅ "one buzzing sodium bulb 30 meters back, wet brick, standing water, no other figures"
- ❌ "fast chase" → ✅ "carves through traffic at 110 km/h, leg dragging outside the lane line on turn-in"
- ❌ "she looks massive next to him" → ✅ "she stands the height of two of him stacked"
**Measurables the model reads:** speed in km/h · atmosphere as density plus visible depth · scale by stacking humans · direction from the camera's point of view ("screen-left") · emotion rendered in muscle · environmental contact rendered physically.
---
## PHRASING: POSITIVE DEFAULT, SANCTIONED NEGATION BATTERIES
Default to stating what happens, not what shouldn't. Negative language usually weakens the signal — the model sees the noun and rounds toward it.
**Five negation batteries are locked and mandatory**, because they suppress known failure modes that positive phrasing does not fix:
1. **The atmosphere battery** — the still-haze negation list
2. **The cadence battery** — the anti-interpolation list
3. **The realism tail** — the closing "no CGI, no rendered look, no AI smoothness…" run
4. **The population lock** — "no other people anywhere in frame" when a scene must be empty
5. **The on-screen text suppression** — its own standing ALL-CAPS directive block, high, never in Last Frame
Everywhere else, ship positive.
---
## THE TWO CAPTURE FAMILIES
Every prompt is written in one. The user picks; don't switch mid-prompt unless they ask.
### Family A — CINEMA CAPTURE
Native 24 fps, true 180-degree shutter, real 1/48 second exposure on every frame. Genuine photographic motion blur. Vintage 2x anamorphic character at a wide aperture — oval bokeh, horizontal streak flares, halation bloom. Shallow depth of field. Color-negative rendition with fine 35mm grain. Handheld with real operator body weight unless locked-off is requested.
### Family B — PHONE / BTS CAPTURE
Native 30 fps, fast electronic shutter — motion crisp and slightly clipped, not softly blurred. Digitally sharp with heavy edge sharpening and high micro-contrast. Deep phone depth of field. Visible rolling-shutter skew on whips, vertical lines leaning and springing back. Aggressive automatic exposure that visibly hunts and pumps. Automatic white balance shifting between zones. Phone HDR tone-mapping — lifted milky shadows, compressed highlights, no deep blacks. Slightly overcooked saturation. Fine digital luminance noise, **not** film grain. Hard clean digital flare with tight star points. Stepped digital zoom when magnifying.
**Phone capture must explicitly kill the film grammar.** Every phone prompt carries: *no anamorphic character, no oval bokeh, no horizontal streak flares, no 35mm grain, no color-negative rendition, no cinema camera look, no 24fps cadence, no 180-degree shutter blur, no shallow cinema focus, no cinematic grade.* Without it the model splits the difference and returns something that reads as neither.
---
## MODE SELECTION (INTERNAL ONLY)
Five modes govern movement, diffusion, grade, and palette. **The mode is never written into the prompt.** Seedance and Higgsfield don't read it. It's a selection tool that decides what the other blocks say.
| Mode | Use when | Movement | Grade |
|---|---|---|---|
| **M1 Narrative** | Real-world dramatic — streets, interiors, sets, lived-in anywhere | Handheld with operator breath | Color-negative, fine 35mm grain, teal-amber |
| **M2 Studio** | White void, clean studio, editorial, fashion film, portrait | Locked or slow push | Saturated editorial, warm-retained blacks |
| **M3 Action** | Combat, chase, stunts, debris, smoke | Handheld and shaky throughout | Heavier low-light grain, dusty haze |
| **M4 Performance** | Stage, arena, lipsync, singing, choreography to camera | Mixed handheld pit and orbital, hard cuts | Stage color cast, streak flares, heavy haze |
| **M5 Atmospheric** | Environment plates, mood, bodies-as-texture, no dialogue | Locked-off, slow push, or wide roaming | Palette-driven, atmospheric |
Once selected, the mode expresses itself through the camera physicality register, the grade language in the closing block, and the diffusion and haze density — never through a label.
---
## DYNAMIC REGISTER (ENERGY DIAL)
Mode says *what kind of scene*. Dynamic register says *how hot the camera runs*. It governs the camera physicality ladder, the cut rate, the cant range, and how much of the frame is allowed to be still. Set it before writing a single block — it changes every downstream choice.
| Tier | Name | Camera | Cant | Cuts | Frame stillness |
|---|---|---|---|---|---|
| **1** | **Composed** | Locked-off, or an extremely slow push or pull. Tripod-weighted. | 0° | 12 shots over the full runtime, or a oner | Long held frames. Stillness is the subject. |
| **2** | **Elevated** | Gentle handheld with breath and float, or slow deliberate dolly and crane moves. Unusual but calm angles — high overhead, low tabletop, tight profile. | 310° | 35 shots, 2.54s each | Frames settle and hold before moving on. |
| **3** | **Kinetic** | Heavy handheld. Tracking, orbiting, pushing. Operator weight readable. | 1225° | 46 shots, 1.52.5s each | Every frame mid-move, but the eye can still land. |
| **4** | **Violent** | Violent handheld. Punching in, ripping back, whipping, hard surges. | 2545° | 46 shots, 1.52s each, plus internal surges | Nothing settles. The frame never lands. |
**Deduce first, ask only if genuinely split.** Read the user's description for these cues:
- **Toward Tier 12:** grief, memory, waiting, ritual, morning, solitude, landscape, portrait, intimacy, an object being handled carefully, a room before anyone arrives, dialogue that matters, the words "quiet," "still," "slow," "beautiful," "elegant," "peaceful"
- **Toward Tier 34:** a beat drop, choreography, a chase, a fight, a crowd, a reveal, a crash, strobe or flashing light, a named BPM, the words "hype," "chaotic," "aggressive," "energy," "hard," "go crazy"
Ambiguous cases are real: a character walking into a room could be Tier 1 dread or Tier 4 arrival. A performance could be Tier 2 restrained or Tier 4 full-out. When the description genuinely supports two tiers, ask once, in one line, offering the two that actually fit:
> "Reading this two ways — is it composed and held, or do you want the camera hot and punching?"
Never ask when the description has already answered. Never offer all four when only two are live.
**Register can be mixed deliberately** — a Tier 1 subject inside a Tier 4 camera is a real and powerful choice (a woman moving slowly while the camera tears around her). When the user asks for that, state the split explicitly in a directive block so the model doesn't average the two into a mush.
---
## REFERENCE TAGS
References are indexed image and video tags in the platform's native syntax:
- Images: `@[Image 1](image_1)``@[Image 9](image_9)` — nine maximum
- Video/audio: `@[Video 1](video_1)`
**Ordering convention:** character sheets first in narrative order, then group/corps wardrobe sheet, then prop sheets, then environment plate last. Audio or video reference last of all.
**Every character gets their own slot.** A character visible inside an environment plate still gets a separate canonical reference and a separate Subject Lock. The plate carries the world; the sheet carries identity.
**The corps or group wardrobe sheet is its own slot**, separate from any formation or staging plate. Two different references — collapsing them loses the wardrobe.
**Renumber cleanly.** If a reference is added, renumber every tag in the body and reissue the list. Never leave a gap or a stale index.
---
## DELIVERY FORMAT
Three parts, in this order:
**1. Bolded title line with runtime.** Names the scene and states the duration.
`**Fur couch — hype ending — 8s**`
**2. Numbered reference list.** One line per reference, as a numbered list — never a prose paragraph.
```
1. @[Image 1](image_1) — [what it is]
2. @[Image 2](image_2) — [what it is]
3. @[Video 1](video_1) — [what it is]
```
**3. One fenced code block** containing the prompt.
Nothing else. No preamble, no explanation, no post-amble — unless a conflict needs flagging, which goes in one or two lines above the title.
**On iterations — deliver directly.** Any tweak to an already-approved prompt (palette, framing, pose, lens, lighting, wardrobe, staging, duration) ships as the revised full prompt with no confirmation bullets. Re-check only on a full scope change: new scene, new character set, new capture family.
**Always ship the full prompt.** Never partial swaps or "replace this line," unless a targeted patch is specifically requested.
**Split rather than overload.** Two camera vantages on the same action are two prompts. A sequence past 15 seconds is two prompts. Say so and deliver both.
**Foreign-language dialogue** ships as a mini script in the response body — English, formatted for a translator — separate from the code block. Inside the prompt, specify the language spoken, the line verbatim, and *"no captions, no subtitles, and no burned-in translation of any kind on screen at any point."*
---
## BLOCK ORDER (LOCKED)
Inside the single fenced code block, in this order:
```
1. Shot count + total duration + per-shot timecodes + cut policy + speed policy
2. CAPTURE CADENCE (cinema) or CAPTURE FORMAT (phone)
3. NO ON-SCREEN TEXT — CRITICAL (mandatory, always first directive block)
3b. Remaining ALL-CAPS CRITICAL directive blocks — as many as the scene needs
4. Subject Lock — @[Image N](image_n) (one per named character)
5. Corps Wardrobe Lock / Crew Lock (group uniforms)
6. PROP LOCK — [name] (any prop that must render exactly)
7. World Plate (anchored to the plate tag)
8. THE ATMOSPHERE — CRITICAL
9. SHOT N — 0.0 to X.Xs blocks (one per shot)
10. Cross-Frame Rules
11. Last Frame
12. Sound Bed
13. Camera & Capture Realism (merged closer — always last)
```
No mode line. No prose between blocks.
---
## BLOCK 1 — SHOT HEADER
States shot count, total duration, every shot's in and out point, cut policy, and speed policy.
```
4 shots. Total duration 8 seconds — shot 1 runs 0.0 to 2.0s, shot 2 runs 2.0 to 4.0s, shot 3 runs 4.0 to 6.0s, shot 4 runs 6.0 to 8.0s. Hard cuts between them, no transitions, no dissolves. All shots at normal speed.
```
Single take: `1 continuous shot. Total duration 10 seconds, no cuts, no transitions, no dissolves. Normal speed throughout.`
If slow motion is deliberately excluded, say so explicitly: *no slow motion, no overcranking, no ramping, and no speed change anywhere in this sequence.* The model volunteers slow motion unprompted on stylized material.
If cuts must never land mid-word (lipsync): *the cuts fall between words and never inside a word.*
**Per-shot timing must sum exactly to the stated total. Maximum total duration is 15 seconds.** Anything longer splits into separate prompts.
**Runtime guidance:** 1.52.5s per shot for high-energy cutting · 2.54s for narrative beats · 47s for a held lipsync line · 815s for a continuous take.
---
## BLOCK 2 — CAPTURE CADENCE / CAPTURE FORMAT
The single most important anti-artifact block. Goes second, always, before anything else competes for attention.
**Cinema version:**
```
CAPTURE CADENCE — CRITICAL: captured natively at 24 frames per second with a true 180-degree shutter angle, a real 1/48 second exposure on every single frame. Every frame carries genuine photographic motion blur and each frame blends smoothly into the next. Motion is fluid, filmic, and continuous. Never choppy, never stuttering, never staccato, never juddering, and never stepping between positions. No frame interpolation, no frame blending, no digital smoothing, no ghosting, no double-imaging, no dropped frames, no high-shutter crispness, no video look.
```
**When the scene contains strobe or flashing light**, add the quarantine — it moves the stepping onto the light and off the footage:
```
The stuttering, stepped quality of this sequence comes entirely from the strobe lighting described below — from bodies being revealed only in discrete flashes — and never from broken or choppy footage. The camera motion between flashes is continuous and smooth even while the bodies appear to jump between positions.
```
Without the quarantine the model returns genuinely broken footage.
**Phone version:** the Family B spec, written out in full in this slot at the same priority.
---
## BLOCK 3 — ALL-CAPS CRITICAL DIRECTIVE BLOCKS
The defining feature of this grammar. Any element the model routinely drops, softens, or gets wrong is promoted out of the descriptive body into its own named block in capitals near the top.
Format: `THE [THING] — CRITICAL:` or `THE [THING] IS THE DEFINING FEATURE OF THIS SEQUENCE — CRITICAL:` followed by an exhaustive paragraph.
### The mandatory first directive block
**Every video prompt carries this block, always, as the first directive block — immediately after capture cadence.** It never appears in Last Frame and never appears only at the bottom. Overlay text is generated early in the frame, so the instruction must sit early in the prompt.
```
NO ON-SCREEN TEXT — CRITICAL: no on-screen text of any kind anywhere in frame at any point. No captions, no subtitles, no burned-in dialogue, no auto-captions, no karaoke text, no lower thirds, no titles, no title cards, no credits, no watermarks, no logos, no timecode, no UI overlays, no social-media overlays, no interface elements. The frame is clean of all overlay graphics from first frame to last.
```
This block does not count against the six-block cap.
**Never carve out in-world text inside this block.** No "other than," no "except for," no exception clause of any kind — an exception clause reopens the door and the model renders captions. Physical text that genuinely exists in the scene (garment prints, packaging, signage, book spines, screens) is described separately, elsewhere, as a physical object with shape, color, placement, and legibility. The suppression block stays absolute.
**Weight it hardest on phone, selfie, and talking-head prompts.** Those pull captions straight from social-media training data and fail most often.
| Block | Use when |
|---|---|
| `NO ON-SCREEN TEXT` | **always — mandatory, first, exempt from the cap** |
| `THE SINGING` | any lipsync — first after the text block, top content priority |
| `HER MOUTH IS ALWAYS VISIBLE AND ALWAYS READABLE` | any lipsync, paired with the above |
| `THE STROBE` / `THE LIGHT` | flashing, pulsing, or any non-obvious lighting behavior |
| `THE CAMERA` | when the camera behavior is itself the style |
| `THE STAGING` | when who-stands-where must not drift |
| `THE ATMOSPHERE` | always, when haze is present |
| `THE TONE` | comedic, warm, or emotionally specific scenes that could be misread |
| `THE LANGUAGE` | foreign-language dialogue, no-caption instruction |
| `NOBODY ELSE IS IN THE FRAME` | any scene that must be empty of extras |
| `THE VANTAGE` | when camera distance or position must not creep |
| `THE BEAT` | when the dramatic point of the shot is a specific reversal |
| `THE LIGHT CHANGE` | when lighting shifts mid-take and the shift is the spine |
**Rules:**
- Cap at roughly six. Past that they compete and all of them dilute.
- Order by importance — the model weights early text more heavily.
- Each block is exhaustive within itself. Never distribute one idea across two blocks.
- The block is the instruction; SHOT blocks and Cross-Frame Rules only *apply* it. Never contradict a directive block downstream — and never restate one in full downstream either. Say it once, at full weight, at the top.
**Anti-redundancy, universal.** Every fact belongs to exactly one block. Wardrobe lives in Subject Lock and is never re-described in a SHOT block — SHOT blocks name a garment only when it is doing something visible (a hem swinging, a strap catching light). Lighting lives in its directive block or the World Plate, not both. Atmosphere lives in Block 8 only. A prompt that repeats itself reads as long without reading as specific, and the duplicated phrasing dilutes the original.
---
## BLOCK 4 — SUBJECT LOCK
One discrete block per named character. Never jam two into one paragraph.
```
Subject Lock — @[Image 1](image_1): [build and skin] [face and bone structure] [hair color, length, styling, permanent features] [makeup] [clean-face negations] [wardrobe head to toe] [jewelry, nails] [role or position in the scene].
```
**Target 90140 words per Subject Lock.** Tight enough that four locks don't swamp the prompt, detailed enough that nothing drifts.
**Wardrobe is restated in every prompt, every time** — but written economically. One clause per garment, not one paragraph. Name: color, fabric, cut, and how it sits on the body. Include hardware only when it's an identity marker (a signature carabiner, a chrome hinge, a studded waistband). Skip stitching, seam construction, and pocket counts unless the user has flagged them.
-*"a long-sleeved charcoal-grey cropped top in soft washed matte jersey with a faded mineral-dyed finish, high round neckline, long slim sleeves fitted to the wrist, front panel wide and solid across the chest, hem sitting just below the ribcage, the back left open and held only by the neckline and shoulders"*
-*"a cropped charcoal washed-jersey long-sleeve with a high round neck and an open back, hem just under the ribcage"*
Both lock the garment. The second leaves room for the rest of the prompt to matter.
**Permanent identity features are declared permanent.** If a character always has blunt bangs, write *"the bangs are permanent and present in every frame."* If a mask is never removed, say so here and again in Cross-Frame Rules.
**Clean-face negations are explicit:** *"Clean clear face, no beauty marks, no rhinestones, no facial markings, no tattoos."* The model invents facial detail otherwise.
**Never use character names.** Refer by hair color, wardrobe, and identity markers — *"the woman with the long jet-black hair."* This holds in every block including staging and Cross-Frame Rules.
**Close with the scene role** when staging matters: *"She is seated upright on the couch and never lies down."*
**Skin lock:** warm fair or very fair as specified, *rendering true and natural, never cool-shifted, never pale porcelain.*
### Corps Wardrobe Lock / Crew Lock
Group uniforms get one shared block anchored to the group sheet: the full uniform, the permitted variation range (*"some wear a sheer mesh layer, some have bare arms, some wear fingerless gloves"*), and the anonymity lock (*"every masked face identical in styling, no dancer ever unmasked, no faces visible beyond the eyes"*).
Crew Locks specify ethnicity, wardrobe color, mask state, and equipment carried. If the crew wardrobe should read plain and anonymous, close with *"no branding, no printed graphics, and no visible text on any garment"* — but that is a styling choice for that scene, not a standing rule.
### PROP LOCK
Any prop that must render exactly: material, scale relative to a hand or body, hardware, surface finish, how it's held, what it does.
---
## BLOCK 7 — WORLD PLATE
Anchored to the plate tag: `World Plate: Anchored to @[Image 6](image_6).` Then the environment — the hero object, its construction and material, the surrounding space, the floor, the light sources built into the set, what falls away into darkness.
Close with the emptiness lock when the space must stay bare: *"no props, no set dressing, and no other structure anywhere in the space."*
Re-describe the plate even though it's attached. The plate anchors look; the text anchors persistence across shots.
---
## BLOCK 8 — THE ATMOSPHERE
Haze is the most drift-prone element in the grammar. It gets its own CRITICAL block with a mandatory negation battery.
```
THE ATMOSPHERE — CRITICAL, depth only: extremely heavy suspended atmospheric haze fills the entire space at very high uniform density, hanging completely still and motionless for the full [X] seconds, fine suspended particulate saturating every plane. It sits thick between every plane so depth reads in clearly separated layers — [name the actual planes of this shot, nearest to furthest, and how each softens]. Bodies pass through it without disturbing it, leaving no wakes and no trails. It reads as thickened still air only — no drifting, no currents, no plumes, no banks, no wisps, no tendrils, no swirls, no rolling, no fog machine texture, no smoke shapes, and nothing that ever reads as fog or smoke.
```
**The negation list is mandatory and complete.** Drop items and the corresponding artifact returns. If a scene contains one legitimate moving vapor (steam off a hot cup, breath in cold air), carve it out explicitly as the sole exception.
**Haze is for depth separation, never for mood.** Always name the actual planes of the actual shot.
**Clean-air scenes** state it just as hard: *"the air is clean — no haze, no fog, no smoke, no atmospheric density, no visible light beams, no suspended particulate."*
---
## BLOCK 9 — SHOT BLOCKS
One per shot, timecoded, in four labeled parts.
```
SHOT 2 — 2.0 to 4.0s. [ALL-CAPS SHOT NAME]. Camera move: [height, position, the move, the physicality, the cant range in degrees, the never-settles clause]. Subject action: [what every visible body does, in order]. Position: [x% placement of each subject and what fills the rest of the frame]. Sound: diegetic only.
```
- **Camera move** always names height, position, move, physicality, cant range, and closes with *"never settling, never locking off."*
- **Subject action** covers every visible figure. Silence about a body means it drifts.
- **Position** uses x% for asymmetric compositions, prose for classical ones.
- **Sound** is a one-word pointer; the real audio lives in Sound Bed.
**Every shot differs in angle and height from every other shot.** Restate this in Cross-Frame Rules.
**Shot naming** in caps summarizes the grammar: `WIDE, LOW, TRACKING LATERALLY` · `CLOSE ON THE HANDS, SLAMMING IN` · `OVERHEAD, LOOKING STRAIGHT DOWN` · `HIGH THREE-QUARTER, CRANING BACK AND OUT`.
---
## BLOCK 10 — CROSS-FRAME RULES
The continuity contract. **It covers only what could drift *between* cuts — it is not a summary of the prompt.**
**The no-restatement rule.** If a directive block already locked something, Cross-Frame Rules does not say it again. The directive block is the instruction and it already carries full weight at the top; repeating it here buys nothing and costs length. Restate only where the risk is specifically continuity — a thing that holds in shot 1 and slips by shot 3.
What this cuts in practice:
- Atmosphere is locked in Block 8 — do not re-describe the haze here, only "holds uniform across all shots, never drifts"
- The cadence quarantine is in Block 2 — reference it in one clause, do not rewrite the battery
- Wardrobe is exhaustive in each Subject Lock — here it is one line: identical to each tagged reference, one look per person, no mixing
- Clean-face negations and permanent identity features get a short list of the markers, not their full descriptions
Standard contents, in order — each one line, not one paragraph:
1. Lighting behavior across every frame
2. The cadence quarantine restated, if strobe is present
3. Who is in the sequence and who is not
4. Staging locks — who stays where, who never stands, who never enters
5. Movement quality locks — who moves slow, who moves hard, who never dances on the beat
6. Permanent identity features restated (bangs, masks, mic hand)
7. Wardrobe identical to each tagged reference, one look per person, no mixing
8. Environment identical across all shots
9. Every shot a different angle and height
10. Camera handheld throughout, frame never settles square
11. Atmosphere holds uniform, never drifts, never reads as fog
12. Skin protection
13. Performance negations — no mouthed words, no singing, no teeth
---
## BLOCK 11 — LAST FRAME
The exact final composition. Every body's position and state, what the light is doing, what the camera is still doing mid-move. A freeze-frame description.
**No text suppression here.** That lives in its own mandatory directive block at the top. Never restate it at the bottom — a second pass at the same instruction late in the prompt adds length without adding weight.
Last Frame describes composition only. Nothing else.
---
## BLOCK 12 — SOUND BED
**Default: diegetic only.** Specific physical sounds tied to specific surfaces and materials — footsteps naming the surface, fabric by type, hardware, breath, room tone, environmental ambient. Close with *"No music, no lyrics, no dialogue, no singing."*
Never write song references, lyrics, track-tied dialogue, or phone-filtered vocal lines. Music is uploaded separately as an audio reference.
**Lipsync exception — HARD LOCK.** When an audio or video track is attached, it is the sole and complete audio source:
```
Sound Bed: The attached clip @[Video 1](video_1) is the sole and complete audio source for this sequence. Generate no additional audio of any kind — no room tone, no foley, no ambience, no breath, no added dialogue, no music.
```
The attached clip also owns all internal timing. Never impose per-beat timing on a lipsync take.
**Spoken dialogue is allowed** when a scene has real speech. Write the line verbatim in quotes and specify delivery physics — mic distance, reverberation, compression, pitch level, accent.
**Phone-captured audio** gets its own physics: *thin, compressed, close, with handling noise from the grip and clipping on the loudest transients.*
---
## BLOCK 13 — CAMERA & CAPTURE REALISM (MERGED CLOSER)
One block, always last. Gear and physics together — the lens spec sits at the bottom of the prompt, where the FOV lock holds best.
Six parts, in order:
**1. Capture register and lens.** Wide-latitude cinema capture or phone capture · lens character and aperture · **FOV in degrees with mm in parentheses, per shot, inline.**
**2. Camera physicality.** The full handheld description, cant range, focus behavior, what carries depth separation.
**3. Rendition.** Film stock and grain, or phone processing. Frame rate, shutter, exposure, total runtime.
**4. Grade.** Palette, where blacks sit, what blooms, what flares specular, what holds saturation, what clips and what doesn't. Attach every color to a fabric, surface, or light source — never a bare palette list.
**5. Skin.** Locked language:
> Skin reads true cinematic matte — zero shine on forehead, nose bridge, cheekbones, and collarbones, real fine even pore texture, real peach fuzz at the jaw and hairline, real lip surface texture, light absorbed like true subsurface scattering, warm fair skin protected and rendering true and natural, never plastic, never doll-skin, never harsh — no acne, no blemishes, no enlarged or rough pores, fine flattering texture that keeps every face looking good.
**The flattering ceiling is locked.** Realism never makes a face look ugly. Where matte-realism and flattering conflict, resolve toward flattering.
**6. Material run and negation tail.** Every material in the scene with its real physical behavior — *"real long shaggy faux fur with real individual fiber structure, real compression under weight, and real recovery."* Then the tail: *no CGI, no rendered look, no digital cleanliness, no plastic surfaces, no AI smoothness, no skin smoothing, no glow, no stiffness, no frozen posing, no stabilized camera, no smooth gimbal movement, no video-look high-shutter crispness, no frame interpolation, no frame blending, no dropped frames, no fog, no smoke, no drifting currents, no plumes, no wisps, no clear air between planes.*
Tune the tail to the scene. Phone prompts invert several items (*no cinematic grade, no film grain, no anamorphic look*).
**Never state aspect ratio.** Aspect is set in the platform UI.
---
## FOV DEGREE TABLE (LENS ANCHOR)
The model latches onto **FOV in degrees** as a snap value — degrees read as instruction, millimeters read as suggestion. Write the degree first with mm in parentheses. Pick from the anchor steps; never write an off-ladder value like 23°.
| FOV | mm equiv | Feel | Use for |
|---|---|---|---|
| 180° | fisheye | spherical bulge | POV, dream-state, hallucination |
| 107° | 1416mm | architectural ultra-wide | vast interior scale, epic establishing |
| 84° | 2024mm | wide | full-body group blocking, environmental establish |
| 63° | 2835mm | reportage wide | observational, walking-alongside, doc feel |
| 47° | 4050mm | eye-level neutral | universal medium, two-shot, waist-up |
| 29° | 7585mm | portrait compression | isolated bust, tight coverage |
| 18° | 100135mm | portrait tight | identity-hold close-up, held emotional beat |
| 12° | 180200mm | tele detail | hand insert, object close, jewelry, texture |
| 8° | 300400mm | extreme long-lens | anchored-far observation, broadcast, watchtower |
Write as: `47° (50mm) eye-level neutral in shot 1, 18° (100mm) portrait tight in shot 2.` Never mm alone. Never an off-ladder degree.
**Extreme-FOV multishot** (8° or 107° across several beats) drifts fastest. Four locks required together: an anchor reference held across every beat · the FOV declared at the top of every beat · the FOV repeated at the close · every color tied to a surface and a light source rather than listed bare. Drop one and it drifts by beat three.
---
## THE LIPSYNC PROTOCOL
Lipsync fails for four diagnosable reasons: the lyric was stated abstractly rather than as a score; the mouth got obscured; too many cuts forced per-shot mouth re-initialization; other CRITICAL blocks out-competed the singing instruction.
Five parts. Use all five.
### 1. Promote the singing to the top block
```
THE SINGING IS THE PRIMARY SUBJECT OF THIS SEQUENCE — every other element is secondary to it. [Description by hair and wardrobe] sings out loud, full voice, mouth open and working hard, for all [X] seconds without stopping. She is a singer delivering a vocal straight down the lens, not a performer mouthing along. Her mouth is the focus of every shot.
```
### 2. Write the lyric verbatim, then the mouth mechanics word by word
Bilabial consonants — **B, M, P** — get maximum emphasis. A visible lip seal is what the eye reads as real lipsync.
```
"TIME" — the tongue taps up behind the teeth on the T, the mouth opens wide on a broad AH that travels into an EE, then BOTH LIPS PRESS FULLY AND VISIBLY TOGETHER AND SEAL SHUT on the M at the end — a complete, unmistakable, hard lip closure with the upper and lower lips meeting flat and pressing together, held for a beat before releasing.
```
Non-bilabial words still get formation: where the tongue goes, how far the jaw opens, whether the lips round or spread, whether teeth touch lip.
### 3. State the closure count
```
THE PATTERN OF CLOSURES: four hard lip seals across the sequence — on the M ending "TIME," the M starting "ME," the B starting "BEEN," and the B starting "BEFORE" — plus a smaller visible closure on the P of "UP." Every one of the four hard seals is complete, fully visible, and unmissable. The mouth is never lazily half-open and never mumbling between them.
```
**Building a closure map:** scan the line for B, M, P — those are the hard seals. F and V are teeth-on-lip, described but not counted. Sustained final vowels are declared held open.
### 4. Lock mouth visibility in its own block
```
HER MOUTH IS ALWAYS VISIBLE AND ALWAYS READABLE — CRITICAL: her face is turned toward the lens and her mouth is unobstructed, frontal, and clearly readable in every single frame of every shot, and it stays readable through the camera movement, through the cant, and through every flicker of the light. Nothing ever covers it — no hand, no hair, no arm, no other body. She never turns her mouth away from the lens.
```
### 5. Hand timing to the clip and minimize cuts
```
Do not invent or impose any internal timing on the singing — the attached clip @[Video 1](video_1) owns the timing entirely, and every syllable, vowel opening, and lip seal syncs precisely to the vocal in that clip.
```
Prefer one continuous take. If cutting, cut between lyric lines or in breaths, never mid-word, and state that in the shot header.
**Strobe fights lipsync.** Hard flash-to-black eats roughly half the closures. When both are wanted, flag it and soften the strobe on the singer only — a fast bright flicker that never drops her face fully to black — while background bodies keep the full treatment. Say so in both THE LIGHT and Cross-Frame Rules.
---
## STROBE GRAMMAR
```
THE STROBE IS THE DEFINING FEATURE OF THIS SEQUENCE — CRITICAL: the space is lit by hard white strobe flashes firing relentlessly on a fast [BPM] beat per minute pulse. The rhythm is flash, black, flash, black — hard on, hard off, with occasional double and triple stutter runs. Each flash is instantaneous and brilliant, revealing the scene crisply frozen mid-motion, hard-edged and contrasty. Each black interval drops the frame to near-total darkness. There is no fade in and no fade out — every transition is a hard snap. Because the bodies are moving continuously but are only visible during the flashes, every figure appears to jump between discrete frozen positions. Nothing ever sits at a comfortable normal exposure at any point.
```
Always pair with:
- A **secondary light** holding a dim constant glow between hits so forms stay readable in the black
- The **cadence quarantine** in Block 2
- A **continuous-motion clause**: *"Nothing is ever frozen, held, or static in any performance between flashes — every body is in continuous motion at all times, it is only the light that stops them."*
**Per-beat light pulsing causes perceived choppiness.** If the user reports choppy output on a pulsing-light prompt, soften the pulse to a slow continuous swell first; if it persists, kill the pulse entirely and go constant.
---
## MOVEMENT AND CHOREOGRAPHY
Name four layers, always, even when a layer is "nothing else moves":
1. **Character motion** — physical actions across the runtime
2. **Micro-motion** — breath, hair, fabric, jewelry, chain swing
3. **Environmental motion** — water, particles, fabric in air
4. **Camera motion** — lives in the closing block, not here
**Synchronized group choreography** needs the unison lock plus the anti-mannequin clause: *"Every dancer hits the same shape at the same moment while carrying her own micro-timing, head angle, and limb height inside the count so the corps never reads as identical mannequins."*
**Contrast staging** — slow figures against a hard-count corps — must state the contrast is the point and lock the slow figures with *"never dance on the beat, never move sharply, never snap."*
**Hair and fabric as motion** is a first-class instruction on high-energy shots: hair whipping across faces and being pushed clear, fabric lifting and settling, chains swinging with real momentum. It reads as physical truth more than any body description.
---
## CAMERA PHYSICALITY LADDER
Four registers, mapping one-to-one onto the dynamic register tiers. Pick one per prompt and hold it.
| Register | Cant range | Language |
|---|---|---|
| **Gentle handheld** | 310° | floating, drifting, riding breath, small organic corrections |
| **Heavy handheld** | 1225° | jolting, bobbing, kicking, lurching, snapping in corrections, high-frequency vibration underneath, framing slipping and hauling back |
| **Violent handheld** | 2545° | punching in and ripping back, hard fast surges, violent corrections, every frame mid-move |
| **Locked-off** | 0° | opt-in only — user must ask for it by name |
**Every register except locked-off closes with:** *"never locked, never stabilized, never mechanically smooth, never gimbal-glide, every frame mid-move, but always smooth and continuous in its own travel."*
That last clause matters. Without it, violent handheld returns broken footage rather than energetic footage.
**Dutch cant is a swinging range in degrees**, plus *"never passing through level, never settling square."*
---
## HOUSE RULES
**No character names anywhere in the prompt body.** Visual description only — hair color and style, wardrobe, identity markers. Applies universally, including staging and positioning.
**No mode line in the prompt.**
**No aspect ratio.** Set in the UI.
**No internal production context.** No "carried through from the previous scene," no "matching the earlier plate." Every prompt is standalone with all details restated fresh.
**No platform or tool names** in the prompt body.
**No meta-commentary.** Every word describes something visible or audible.
**Age-blind.** Describe by role, hair, wardrobe, identity markers.
**English only inside the code block.**
**Brand names, text, and graphics are written verbatim.** If the scene has a Heinz ketchup bottle, write "a Heinz ketchup bottle" and describe the label — the glass shape, the keystone label, the printed lettering. Same for a Nissin Cup Noodles cup, a Coca-Cola can, a specific car model, a wordmark on a tee, a slogan on a slate. Naming the thing renders the thing; a generic paraphrase renders a vague approximation of it. Describe the physical marks alongside the name so the model has something to draw: shape, color, placement, legibility.
**Lighting is described by direction, quality, and temperature — never by fixture name.** No named lamps, no LogC4, no IRE, no stock codes in the body.
---
## STORY BIBLE HANDOFF
If a story bible or canon skill is active in the session, treat it as the identity and context source and this skill as the cinematography grammar. Pull from it:
- **Character voice, movement signature, and stillness register** → into Subject Lock state descriptors and the Subject action lines of each SHOT block
- **Speech patterns and cadence** → into Sound Bed when a scene has real dialogue
- **Aesthetic era, palette, and world texture** → into the grade portion of the closing Camera & Capture Realism block
- **Production rules and locked conventions** → layered on top of House Rules, taking precedence where they conflict
The bible answers *who and what world*. This skill answers *how it is shot*. Never let bible material leak into the prompt as lore or backstory — only as observable physical behavior.
Operate standalone when no bible is present.
---
## PRE-DELIVERY PASS
- [ ] Bolded title with runtime, then a numbered reference list, then one code block
- [ ] Every named character has its own reference slot and its own Subject Lock
- [ ] Group wardrobe sheet has a slot separate from any formation plate
- [ ] Shot header: count, total, every timecode, cut policy, speed policy — timings sum, total ≤ 15s
- [ ] Capture cadence or capture format block sits second
- [ ] Strobe scenes carry the cadence quarantine
- [ ] Dynamic register deduced or asked, and cut rate, cant range, and camera physicality all match it
- [ ] No mode line anywhere in the prompt
- [ ] NO ON-SCREEN TEXT block present, first directive block, no carve-out or exception clause inside it
- [ ] Remaining directive blocks ordered by importance, capped at ~six
- [ ] Subject Locks 90140 words each, wardrobe economical but complete
- [ ] Permanent identity features declared permanent, repeated in Cross-Frame Rules
- [ ] Clean-face negations present
- [ ] No character names anywhere
- [ ] Atmosphere block carries the complete negation battery, planes named
- [ ] Every shot block has all four labeled parts, every shot a different angle and height
- [ ] Cross-Frame Rules covers all thirteen standard items, one line each, nothing restated from a directive block
- [ ] Last Frame is composition only — no text suppression, no repeated locks
- [ ] No fact appears in two blocks — wardrobe, lighting, and atmosphere each live in exactly one place
- [ ] Sound Bed diegetic, or the lipsync sole-source lock if a track is attached
- [ ] Closing block merges camera and realism, carries FOV in degrees with mm, no aspect ratio
- [ ] Lipsync prompts carry all five protocol parts and a closure count
- [ ] Phone prompts explicitly kill the film grammar
- [ ] One fenced code block, no prose inside it
**Repair pass:**
- Wardrobe drifting → restate every garment, not just the changed one
- Choppy output → check cadence block position, then soften or kill any per-beat light pulse
- Lipsync closures missing → check mouth-visibility block, cut count, and whether strobe is eating the face
- Bodies drifting between shots → tighten Cross-Frame Rules staging locks
- Haze reading as fog → the negation battery is incomplete
- Extras appearing → add the population lock as its own directive block
- Slow motion appearing unbidden → add the explicit no-speed-change line to the shot header
- Phone footage reading cinematic → the film-grammar kill line is missing
- Captions or subtitles appearing → the text block drifted down the prompt, or an "other than" carve-out crept back into it
- Prompt long but vague → something is stated twice; find the duplicated fact and delete the later copy
- Prompt overloaded or over 15s → split into two prompts by camera or by beat

Binary file not shown.

View File

@@ -0,0 +1,200 @@
---
name: story-bible-builder
description: "Interview-driven skill that helps AI filmmakers, worldbuilders, and storytellers turn their story into a single dense canon document — the story's bible. Output is a ready-to-install SKILL.md the user drops into Claude as their own custom skill, so every future prompt (image, video, music, dialogue, scene) automatically knows the world, characters, voices, tone, plot, and production rules without eating into the user's 30 memory slots. Covers premise, thesis, timeline, factions, locations, characters with voice and movement locks, ensemble dynamics, plot engines, and production rules. Use whenever a user wants to build a story bible, lock in character consistency, capture their world, define character personality and voice, stop prompt drift, or turn a loose idea into an installable canon skill. Trigger on 'build a bible', 'story bible', 'lock my characters', 'character bible', 'canon doc', 'turn my story into a skill', or any close variant."
---
# Story Bible Builder
An interview-driven skill for building a **single dense canon document** — a story's bible — that ships as an installable SKILL.md the user can drop into Claude as their own custom skill.
The point: instead of burning memory slots on world context, or re-explaining the story every chat, the user gets one file that lives as a skill and auto-loads every time they work on their world.
The output is a locked, opinionated, prompt-ready canon doc. Not a template. Not a workbook. A **bible**.
---
## WHAT THIS SKILL IS FOR
Users bring a story world they're building — a film, a series, a game, an album, an AI-generated universe. This skill interviews them across every dimension of that world and assembles it into a single `SKILL.md` file matching the structure below. They install that file as a skill. Future Claude sessions read it and know their world.
**The output is one file.** Not modular. Not multi-file. One dense, canon SKILL.md — because that's what installs cleanly as a skill and stays under 500 lines.
---
## TWO WAYS THE BIBLE GETS USED
Every bible this skill produces is engineered to work in **both** modes below. Design every output to serve both.
### Mode 1 — Standalone canon reference
The user installs the bible as a skill and uses it on its own. Future Claude sessions read the bible when the user asks for anything set in that world — a scene, a dialogue exchange, a character beat, an outfit, a song lyric, a straight text-to-image prompt, a story pitch, a treatment. The bible provides the context so Claude doesn't have to ask.
For this mode, the bible needs to be **dense, opinionated, and self-contained** — no dependencies on other skills.
### Mode 2 — Context source for a video prompt director skill
Many users pair the bible with a video prompt director skill (like `cinema-worldbuilder-pro-20` or similar). In this pairing, the director skill handles the cinematography grammar, mode selection, frame composition, and prompt syntax. The bible provides the identity, voice, movement, aesthetic era locks, and canon that the director skill can't get from a reference image alone.
The director skill reads uploaded reference images for wardrobe, hair, and identity. It cannot read *voice*, *movement quality*, *stillness*, *what era's aesthetic applies*, or *what production rules are locked for this world*. Those come from the bible.
**When both skills are active in the same session**, the director skill should pull directly from the bible for:
- **Character voice descriptors** (goes into Sound Bed / dialogue direction)
- **Character movement and stillness descriptors** (goes into Subject Lock block)
- **Aesthetic era differentiation** (goes into World Plate / grade selection)
- **Production rules** (locked visual traits that must appear in every render — piercings, scars, permanent hair features, "never" clauses)
- **Ensemble dynamics** (informs Cross-Frame Rules when multiple canonical characters share a shot)
The bible's job is to make every one of those descriptors **copy-paste-ready** — quoted, tight, prompt-facing. If a descriptor in the bible can't be pasted verbatim into a Seedance Sound Bed or Subject Lock block, it's written wrong.
---
## THE STRUCTURE OF THE OUTPUT
Every bible this skill produces follows this section order. Not every section applies to every story — skip cleanly when the user says something doesn't apply.
1. **One-line premise** — the whole story in a sentence
2. **The thesis** — the core question every scene comes back to
3. **The world — timeline** — eras, years, what defines each
4. **Aesthetic era differentiation** — the palette/lighting/texture per era (critical for prompts)
5. **Major factions / powers / entities** — antagonists, protagonist groups, third parties
6. **Bases / locations** — where things happen, with visual specificity
7. **The rules of the world** — technology, magic, social systems, what's normal, what's forbidden
8. **The characters** — one deep section per character (structure below)
9. **Relationships and ensemble dynamics** — who fills silence, who leads what, who watches
10. **Structural engines** — the story shapes chapters can take
11. **Production rules** — locked prompt-facing rules the user has learned about their own work
12. **When this skill is active** — instructions to future Claude on how to use the bible, in both standalone and paired-with-director modes
Reference `references/character-section-format.md` for how each character section is structured. Reference `references/example-bible-excerpts.md` when the user needs to see what a good section looks like.
---
## THE BUILD FLOW
### Step 0 — Scope check (fast)
Ask in one compact block:
- What's the working title of the story?
- How many main characters?
- What genre/vibe? (One or two references — films, shows, games, albums, aesthetic movements)
- Does the world already exist in their head, or are we building parts from scratch?
- Which prompt tools will future scenes use? (Seedance, Banana Pro, Midjourney, Suno, ElevenLabs, etc.)
This shapes pacing, section depth, and what production rules to bake in at the end.
### Step 1 — The spine (premise, thesis, world timeline, aesthetic)
Sections 14 of the output. Interview the user through:
- **One-line premise.** Push hard for one sentence. If they give more, help compress. The compression is the value.
- **Thesis.** Not plot. Theme. The question every scene answers. If they can't state it, help them find it by asking "what's your character actually deciding, every time?"
- **Timeline.** Walk era by era. For each, ask: year(s), what defines it, aesthetic differentiation (palette, lighting quality, texture, grain). The aesthetic-per-era block is what keeps future image prompts consistent — push for specificity.
Assistant behavior: never invent. Mark `[TBD]` if the user doesn't know. Push on aesthetic locks — this is where most bibles are weakest. When a user says "dark and moody," ask "what colors specifically?" Reference the demo excerpts in `references/example-bible-excerpts.md` if the user needs to see the level of specificity.
### Step 2 — Factions, locations, world rules (sections 57)
Cover these together — they interlock.
- **Factions.** For each: name, what they do, what they believe, how they read visually (uniform, silhouette, color signature), public face vs actual behavior if there's a gap.
- **Locations.** For each: name, what it is, visual tags (three to seven words), function in the story. Push for the visual — "Berlin bunker" is not enough. Concrete walls, monitor light, cable spaghetti, one warm amber pocket — that's a location.
- **World rules.** Ask about: technology tier and access, magic/powers/abilities (and how they LOOK on screen — color, motion, sound), social systems, what's normal here that isn't normal in our world, what's forbidden or dangerous.
Short declarative bullets, not paragraphs. Dense.
### Step 3 — The characters (section 8, the biggest section)
**One character at a time. Never batch.** Each character gets a deep dedicated pass. This is where most of the bible lives.
For each character, run the character interview in `references/character-interview.md`. It covers:
- **Visual lock** — the physical descriptors that must appear in every prompt
- **Function in the story** — what role they play in the overall narrative
- **Backstory beats** — where they came from, what shaped them
- **Present-tense psychology** — where they are RIGHT NOW in the story, what they're carrying
- **Speech pattern** — register, cadence, vocabulary, signature phrases, dialogue prompt-ready descriptor
- **Movement pattern** — how they move, combat if relevant, gestures, tics
- **Stillness pattern** — what they do when they're not moving (often more revealing than movement)
- **Musical voice if music is in scope** — Suno-ready descriptor
After each character is drafted, show the user their section and ask: "Add anything, cut anything, sharpen anything?" Iterate until they lock it. Then move on.
### Step 4 — Relationships and ensemble dynamics (section 9)
Ask: "When these characters are in a room together, what's the shape of the room? Who leads what? Who fills silence? Who watches? Which pairings are calm, which are charged?"
Capture as short declarative sentences. Example: *"Maren and Wren are the quietest room in the house — neither fills space, both notice everything. Owen and Iris cannot be in the same kitchen without one of them leaving."*
This section is short but disproportionately valuable — it's what makes ensemble scenes feel real.
### Step 5 — Structural engines (section 10)
Ask: "What are the recurring chapter shapes your story runs on? Not specific episodes — the shapes. A heist? A rescue? A performance? A confrontation? A flashback?"
List each engine with a one-line description. Note that engines stack (a heist can end in a confrontation, a performance can be interrupted by a rescue). This gives future Claude a menu of story shapes to pull from when the user asks for a new scene.
### Step 6 — Production rules (section 11)
Ask: "What are the rules you've hard-earned about your own work? The stuff that only works in a specific way. Prompt rules, naming rules, canon lock rules, aesthetic rules that can't be broken."
Bake in these defaults for AI-filmmaker users unless the user overrides:
- No character names in image/video/music prompts (models drift on names) — refer by visual description
- Every prompt is standalone (no "matching the previous scene")
- Output prompts in code blocks, no aspect ratio in the prompt body
- Any locked physical traits get restated verbatim in every prompt
Add anything the user has learned from their own prompt work. Copy their exact phrasing where possible — this is a doc of their rules.
### Step 7 — Assembly and "when this skill is active" (section 12)
Assistant now assembles the full SKILL.md. The final section — "When this skill is active" — must instruct future Claude how to use the bible in **both modes**:
**Standalone mode:**
1. When the user asks for anything in this world (scene, dialogue, character beat, outfit, lyric, prompt, treatment), pull relevant character/world context from the bible and use it
2. Stay inside canonical world (year, locations, rules, relationships)
3. Never invent details that conflict with locked canon — ask instead
4. Use the quoted Speech/Movement/Stillness descriptors verbatim when writing prompts
**Paired-with-director-skill mode:**
1. If a video prompt director skill is also active in the session (e.g., `cinema-worldbuilder-pro-20` or similar), the director skill handles cinematography, mode selection, frame composition, and prompt syntax
2. The bible feeds the director skill: character voice → Sound Bed; movement/stillness → Subject Lock; aesthetic era locks → World Plate / grade; production rules → cross-frame rules and locked traits
3. When the user asks for a video prompt in this world, pull the relevant character's voice, movement, and stillness lines and the correct aesthetic era block, and hand them to the director skill's prompt structure
4. Named canonical character references (uploaded reference images the user always attaches) get called out here so the director skill knows to expect them
Ask the user what production companion skills they use (if any) and name them explicitly in this closing section so future Claude knows the paired workflow.
Ship the file with the YAML frontmatter (name + pushy description) and the full canon body. Save to `/mnt/user-data/outputs/[working-title-slug].md` and present.
Offer to zip it as an installable `.skill` file if the user wants that packaging.
---
## HOUSE PRINCIPLES (HOLD THROUGH EVERY INTERVIEW)
1. **Density over prose.** Bibles are working references, not pitch decks. Short declarative sentences. Bold labels. Bullet points. The user should be able to grep this doc.
2. **Never invent.** If the user doesn't know something, mark `[TBD]` in the doc and move on. Invented canon becomes locked canon becomes prompt drift.
3. **Push on the vague.** "Dark and moody" isn't an aesthetic lock. "Cool voice" isn't a voice profile. "Powerful" isn't a description. When the user gives a vague answer, ask the follow-up that makes it prompt-actionable — color specifics, register, movement quality. Reference `references/example-bible-excerpts.md` if the user needs to see the level of specificity.
4. **Locks exclude as much as they include.** When the user locks a physical trait, ask what the wrong-answer drift would be. "Warm fair skin — never pale porcelain, never tan." The "never" clause is what stops model drift over hundreds of future renders.
5. **Character depth matters more than character count.** Better to ship a bible with three deep characters than eight shallow ones. If the user is trying to cram too many characters into the interview, offer to do the deepest three first and hold the rest for a follow-up pass.
6. **The user's voice, not yours.** When they phrase something well, keep their phrasing verbatim. When they name their own rules, quote them. This is their bible.
---
## REFERENCE FILES
- `references/character-section-format.md` — Structure for each character section in the final output
- `references/character-interview.md` — Questions to walk each character through
- `references/example-bible-excerpts.md` — Sample sections at the right density level to reference or show the user
Load each reference only when its step is active.
---
## WHEN THE USER ONLY WANTS PART OF THIS
Common. If they say "I just want to nail down the characters" — skip straight to Step 3. If they want to update an existing bible, ask them to paste it and work from there. The full flow is the default, not a requirement.

View File

@@ -0,0 +1,137 @@
# Character Interview
One character at a time. Run through these in order. After each character is drafted, show the section back to the user and iterate until locked. Then move on.
The output is one character section in the format defined in `character-section-format.md`.
---
## 1. Function
Start here, not with looks. Ask:
**"What role does this character play in the story? Not their job — their *function*. What do they represent? What are they the emotional/narrative anchor for?"**
Look for a role tag — one to three words. *The Heart. The Watcher. The Chaos. The Grounding.* This becomes the italicized tag after the character name in the header.
Push if they give something generic. "The leader" is a job. "The one who feels most for the humans they're saving" is a function.
---
## 2. Visual lock
Cover in this order:
- **Hair.** Color (specific — "cool ash platinum" not "blonde"), length, cut, permanent features (always bangs, always center part, wispy temple pieces)
- **Skin.** Tone with specifier ("warm fair," "cool fair," "olive medium," "warm deep") + finish (dewy, matte, freckled, natural). Ask what the wrong drift would be — that becomes the never-clause.
- **Face structure.** Jaw, cheekbones, chin, nose, lips — one adjective each. Doll-coded, sharp-sculpted, soft, angular, refined. Push for a committed shape.
- **Eyes.** Shape, size, color, any lift at the outer corner
- **Signature identity markers.** The permanent stuff. Piercings, scars, moles, unique features. Ask what would make someone identify them in a crowd. Every character needs at least one.
- **Body / posture.** Height range, build, default stance
- **Default expression.** Watchful? Warm? Blank? Slight smirk? What does their face do when nothing is happening?
- **Locked "never" clauses.** No beauty mark. No teeth-showing smile. No visible piercings. Whatever the wrong-answer drifts are for this character.
The Visual block gets compressed into one dense line for the final output — but interview them on all these pieces to get it right.
---
## 3. Backstory
Ask: **"Give me the origin and the shaping events. Where did they come from, what made them who they are, what happened before the story starts."**
Keep it brief in the final output — one short paragraph or a few tight bullets. The point isn't to write their biography. The point is to give future Claude enough backstory to make consistent choices in scenes.
Push for **one formative detail** that's specific and small. Not "she had a hard childhood" — "she used to fix the sink in their apartment herself at 3am because the plumber would have asked questions." Small specific detail beats grand backstory summary every time.
---
## 4. Present-tense psychology
Ask: **"Where is this character *right now* as the story begins? What are they carrying? What are they about to have to face? What do they not yet know?"**
This section is what makes the bible feel alive. It tells future Claude where the character is emotionally so scenes stay anchored in the right beat.
Push for the internal contradiction — the thing the character is doing versus the thing they're actually feeling. That gap is where the drama lives.
---
## 5. Speech
Ask separately and assemble into one quoted line:
- **Register:** low, mid, high — chesty, throaty, resonant
- **Texture:** clean, dry, warm, breathy, silvery, gravelly
- **Cadence:** fast, slow, deliberate, syncopated, clipped
- **Volume default:** quiet, medium, loud
- **Vocabulary tier:** plainspoken, precise, slangy, poetic, technical, coded
- **Signature moves:** long pauses before speaking, run-on sentences, self-interruption, filler tics, code-switching
Then write it up as one quoted string. Format:
> *"cool mid-register voice with clean smooth tone, measured volume, surgical sentence construction, long deliberate pauses, sophisticated vocabulary, never raises her voice in anger"*
That's the format. Copy-pasteable into Seedance dialogue prompts and ElevenLabs voice casts.
---
## 6. Movement
Ask separately, assemble into one quoted line:
- **Gesture quality:** economical, kinetic, expressive, minimal, weighted, precise, animated
- **Body carriage:** grounded, aesthetic, centered, poised, contained
- **Combat posture (if relevant):** offensive precision, defensive efficient, reactive counter-punch, wildcard creative
- **Habitual tics:** hand at the back of the neck, hands in pockets, self-adjustment, light physical contact with teammates
- **Gait:** heavy-heeled, silent, prowling, floating, deliberate tempo
Assemble one quoted line:
> *"minimal contained gestures, small precise head tilts, reactive counter-punch combat posture, prolonged steady looking, infrequent blinking"*
---
## 7. Stillness
Often more revealing than movement. Ask: **"What do they look like at rest? When they're not talking, not moving, not doing anything? What do their hands do? Where's their weight? What's their expression? Their breath?"**
Assemble one quoted line:
> *"deep settled stillness, square shoulders, hands crossed at the waist or one hand at the opposite elbow, neutral observing expression, slow breath"*
If the character is never really still (high-energy characters), capture that too:
> *"brief tense stillness, animated stance, weight shifted onto one hip with hand on it, gestural opposite hand, closed-lip almost-smirk expression, chin down eyes up"*
---
## 8. Musical voice (music scope only)
Only if the story involves music (AI-idol projects, musicals, music-driven narratives). Ask:
- Vocal range and register in song
- Timbre when singing (often different from speaking)
- Signature register on stage (belt, whisper-sing, rap, half-rap, head voice)
- Style affinity (pop, R&B, hyperpop, alt, ballad, EDM)
- Signature move (breath control on runs, a specific ad-lib style, a rap flow character)
Assemble a Suno-ready descriptor:
> *"high-register clean-cool lead vocalist with steely precision, strong head voice, restrained vibrato, emphasis on control over warmth"*
Never use artist names or song references in Suno prompts — that gets rejected. Describe sonic qualities only.
---
## 9. Sub-beats (optional)
Ask: **"Any specific locked story threads for this character? A hidden relationship, a secret, a recurring pattern, a running joke, a private ritual?"**
Only include as bold sub-beats in the final section if the user says yes and can name it specifically. Don't force this. Sub-beats are for locked canon threads, not speculation.
Example — a locked hidden thread for a returning-sibling character:
> *"The signing: When the lawyer slides the paper across the table, her hand moves before her face does. She signs. She does not look up. She knows before her siblings know that the document is not what they were told it is."*
---
## Iterate
After drafting the character section, show it back:
> "Here's [character name]'s section. Anything to add, cut, or sharpen? Once you approve, this is locked canon in your bible."
Iterate until they lock it. Then move to the next character.

View File

@@ -0,0 +1,55 @@
# Character Section Format
How each character appears in the final assembled bible. Match this shape.
---
## Structure per character
```markdown
### [NAME] — *[Role tag]*
**Visual:** [Hair] [skin] [signature identity markers — piercings, scars, permanent physical features]. [Face structure — one line]. [Body / posture default]. [Default expression]. [Any locked "never" clauses — no beauty mark, no teeth-showing smile, etc.]
**Function in the story:** [What they represent, what role they play, what they were built/born to be, what they became]
**Backstory:** [Origin, formative period, key pre-story events. One short paragraph or a few bullets.]
**Present-tense psychology:** [Where they are RIGHT NOW as the story begins. What they're carrying. What they don't yet know. What they're about to face.]
**Speech:** "[prompt-ready dialogue descriptor — register, texture, cadence, vocabulary, volume]"
**Movement:** "[prompt-ready movement descriptor — gesture quality, combat posture if relevant, tics, gait]"
**Stillness:** "[prompt-ready stillness descriptor — what they do at rest, hands, weight, expression, breath]"
**Suno (if music scope):** "[prompt-ready singing voice descriptor — register, timbre, style, signature move]"
[Optional bold sub-beats for specific narrative threads — e.g., a hidden relationship, a secret, a recurring pattern. Use sparingly and only when locked.]
```
---
## Rules
1. **The four prompt-ready descriptors (Speech, Movement, Stillness, Suno) must be in quotes.** They're engineered to drop verbatim into future prompts — either as standalone prompt inputs, or as feeder blocks for a video prompt director skill (like `cinema-worldbuilder-pro-20`). Specifically:
- **Speech** feeds the Sound Bed block or dialogue direction
- **Movement** and **Stillness** feed the Subject Lock block
- **Suno** feeds vocal casting for music prompts
That's the whole point — the descriptors are formatted this way so they slot in cleanly.
2. **Never use the character's name inside the quoted descriptors.** Refer by trait ("low-register voice," "cocked-hip stance"). Names drift models. Names live in the header only.
3. **Visual block is one dense line.** Not a bulleted list of features. One flowing sentence-fragment block with commas and periods. Easier to scan, easier to paste into a prompt as a character anchor.
4. **Function comes before backstory.** What they ARE in the story matters more than where they came from. Function is what future Claude uses to write scenes. Backstory is context.
5. **Present-tense psychology is what makes the bible alive.** It tells future Claude where the character is emotionally at the current story moment, so scenes are anchored in the right beat.
6. **Bold sub-beats are optional.** Use them for locked narrative threads (a hidden relationship, a secret, a recurring pattern). Don't force them. If a character's arc is straightforward, skip.
---
## Reference the demo bible
The example excerpts reference file (`example-bible-excerpts.md`) contains a full character section (MAREN from the HOLLOWTIDE demo) in this exact format. If the user has never seen one, show them that section as a reference. That's the density and shape to hit.

View File

@@ -0,0 +1,115 @@
# Example Bible Excerpts
Show these to the user when their answers are too vague, or when they ask "what should this look like." These are density-and-shape references — the level of specificity the bible needs to hit.
All examples come from a fictional demo bible (working title: **HOLLOWTIDE**, a folk-horror ensemble set in a coastal fishing town). It's invented purely for calibration. Use it to show the format, then pivot back to the user's own story.
---
## Example — One-line premise
> *"In a fog-locked fishing town where the tide brings something back every full moon, four estranged siblings return home for their mother's funeral and find out she was the last thing keeping it at bay."*
Not one sentence exactly, but the shape is right — setup, protagonist role, engine, twist.
---
## Example — Thesis
> *"HOLLOWTIDE is a story about what a family owes a place, and what a place owes a family. Every chapter is some version of one question: **What do you inherit that you never agreed to?**"*
Thesis works when it collapses to a single question every scene answers.
---
## Example — Aesthetic era differentiation
Notice how each era gets a distinct visual signature, not just a mood:
> *"- **1962 (flashback era)** — grainy Kodachrome, warm ambers, salt-stained wood, kerosene lamplight, low sun through fog*
> *- **Present day (main timeline)** — desaturated grays and mossy greens, wet stone, sodium streetlight cutting through mist, long lens compression*
> *- **The Tideward (the thing beneath)** — inverted palette, bioluminescent teal against total black, wet reflective surfaces, no natural light ever"*
Each line is copy-paste-ready as the environmental anchor for a scene prompt in that era.
---
## Example — Faction
> *"**The Harbor Council.** The seven oldest families of the town, meeting monthly in the salt-house on the pier. They own the docks, the cannery, the ferry, and every business that touches the water. They present as civic stewards — parades, memorials, scholarships. Underneath: they enforce the tide-pacts. Every generation, one member of one family goes into the water on the last night of the year. The town has voted in that death since 1798. No one outside the seven families knows. Fighting the Harbor Council means fighting the town's memory of itself."*
Faction blocks work when they cover: what they own or do, what they believe, how they present publicly, the gap between public and actual, and why they're hard to fight.
---
## Example — Location
> *"**The salt-house.** A century-old wooden structure at the end of the longest pier, built on stilts sunk into bedrock. Interior: raw plank walls, tallow candles in iron holders, a single long table, seven chairs, no electricity. The council meets here monthly. Nothing has been added, removed, or repaired in ninety years — anything that breaks, stays broken. The floor sags. The candles smoke. The building holds."*
Location works when it has: what it is (function), specific visual tags for prompts, and one poetic line that captures the feel ("The building holds.").
---
## Example — Character section
```
### MAREN — *The Eldest*
**Visual:** Ash-blonde hair, cropped short and uneven, like she cut it herself. Cool fair skin, freckled across the nose and cheekbones. Sharp square jaw, straight nose, thin unpainted lips. Gray-green eyes, downturned at the outer corner, deep set. Small silver ring in the left earlobe, worn since 16. Tall, wiry build, hands too big for her wrists. Default expression: watchful, jaw slightly set. No teeth-showing smile.
**Function:** The one who left and stayed gone. Represents refusal — the sibling who tried to break inheritance by walking away. Learns across the story that walking away is not the same as being free.
**Backstory:** Left town at 19, twelve years ago. Works as a paramedic three states over. Has not spoken to her mother in eight years and to her siblings in three. Came home only when the estate lawyer called. Once, at 15, saw something in the water she has never told anyone about.
**Present-tense psychology:** Braced. Wearing the same jacket she left in twelve years ago because she didn't pack for staying. Carrying the certainty that if she doesn't get out by the funeral, she never will. About to be handed a document that requires her signature to leave.
**Speech:** "low mid-register voice with dry contained tone, quiet volume, short precise sentences, deliberate pausing before answering, plainspoken practical vocabulary, never raises her voice"
**Movement:** "economical gestures, weight settled and grounded, walks with a slight limp in the left leg she doesn't acknowledge, keeps her hands in her jacket pockets when idle"
**Stillness:** "settled watchful stillness, weight evenly distributed, hands in pockets, shoulders slightly raised, jaw set, eyes level and unblinking"
**The signing:** When the lawyer slides the paper across the table, her hand moves before her face does. She signs. She does not look up. She knows before her siblings know that the document is not what they were told it is.
```
That's the density. Every section is one to three lines. Every quoted descriptor is prompt-ready. Every backstory beat is specific ("wearing the same jacket she left in twelve years ago because she didn't pack for staying" — not "she doesn't want to be here").
---
## Example — Ensemble dynamics
> *"**Among the siblings:** Maren leads by refusing to. Owen fills every silence with something practical. Iris asks the question everyone else is avoiding. Wren watches the other three and reports back to no one.*
>
> ***Pairings:** Maren and Wren are the quietest room in the house — neither fills space, both notice everything. Owen and Iris cannot be in the same kitchen without one of them leaving. Maren and Owen fight in complete sentences. Wren has not spoken to Owen since 2019."*
Ensemble dynamics work when they name the *feel* of specific pairings and groupings, not just the relationships.
---
## Example — Structural engine
> *"1. **The council meeting** — a Harbor Council session, always in the salt-house, always at night. A vote is taken. Someone loses.*
> *2. **The tide chapter** — a full-moon night. The Tideward comes closest to surface. Rules of the world bend. Combat, if any, lives here.*
> *3. **The confession chapter** — one sibling tells one other sibling one thing. Whole story shifts."*
Engines work when each is a shape (not a plot), and the user can mix and match them for new chapters.
---
## Example — Production rule
> *"**Locked physical traits, never omitted:***
> *- Maren: ash-blonde uneven crop, small silver earring left ear, slight limp*
> *- Owen: always wearing his father's watch, band cracked, still worn*
> *- All four siblings: cool fair freckled skin (never tan, never porcelain)*
> *- All four siblings: no teeth-showing smiles — default expression is watchful, closed-lip"*
Production rules work when they include the "never" clause. What's excluded is as important as what's included.
---
## The universal signal
Every section should pass this test: **could a stranger who has never heard of this story write a scene in it, using only this bible, and get it right?**
If yes, the section is dense enough. If no, keep pushing.

View File

@@ -6,9 +6,63 @@ KI-Video-Generierung mit lernender Brand-Knowledge-Base (Elo-Scoring über Attri
- **Desktop-Ansicht:** Design-Handoff für die Desktop-Adaption in [design/desktop-ansicht/](design/desktop-ansicht/) (Spezifikation: [README.md](design/desktop-ansicht/README.md))
- **Backend:** selbst gehostetes Appwrite (`https://appwrite.webklar.com/v1`), Projekt **BrandLoop** `6a5cee34002bb8360c34`, Datenbank `brandloop`
## App (`client/`)
Expo / React Native, eine Codebase für Web und nativ. Stand: Gerüst aus E1
drei Tabs, sonst leer.
```bash
npm --prefix client run web
```
## Bildgenerierung
Die Warteschlange aus `jobs` arbeitet der Dispatcher ab **serverseitig**, weil
der Anbieter-Schlüssel nicht ins Client-Bundle darf:
```bash
BILD_ANBIETER=stub node scripts/job-dispatcher.mjs
```
`stub` erzeugt Platzhalterbilder und macht die ganze Kette prüfbar, ohne Kosten.
`ark` und `openrouter` sprechen die echten Modelle an beide setzen voraus,
dass das jeweilige Konto freigeschaltet bzw. aufgeladen ist:
- **BytePlus Ark:** Modell in der Konsole unter Model Service aktivieren
- **OpenRouter:** Guthaben aufladen
Danach reicht `BILD_ANBIETER=ark node scripts/job-dispatcher.mjs` am Code
ändert sich nichts.
## Demo-Daten
Demo-Daten zum Anschauen (Marke, zwei Ordner, vier Modelle mit echten Bildern
aus `uploads/`) legt alles als **Client** an, belegt damit nebenbei die
Rechte, und ist idempotent:
```bash
node scripts/seed-demo.mjs
```
Die Mandantentrennung lässt sich jederzeit nachprüfen legt zwei echte Konten an,
prüft über die API und räumt sich selbst auf:
```bash
node scripts/test-mandanten.mjs
```
Die Appwrite-SDKs sind **exakt** gepinnt (`appwrite@23.0.0`,
`react-native-appwrite@0.25.0`): der Server läuft 1.8.1, neuere SDKs sprechen
Response-Format 1.9.x. Beim Server-Upgrade beide zusammen anheben.
Konfiguration (Endpoint, Projekt-ID, DB-ID) steht in `client/app.json` unter
`expo.extra.appwrite`. Der Appwrite-**Server-Key** gehört dort **nicht** hinein
er landet sonst im Client-Bundle; er steht in der `.env` im Repo-Root und wird
nur von `scripts/` und später von Appwrite-Functions benutzt.
## Datenbank-Setup
Das komplette Schema (15 Tabellen, Indizes, 4 Storage-Buckets, internes Dev-Team) legt
Das komplette Schema (22 Tabellen, Indizes, 5 Storage-Buckets, internes Dev-Team) legt
[scripts/setup-appwrite.mjs](scripts/setup-appwrite.mjs) an idempotent, kann nach
Plan-Erweiterungen jederzeit erneut laufen:
@@ -39,8 +93,8 @@ node scripts/seed-prompts.mjs
- **Referenzen als indizierte String-Spalten (size 64) statt Relationship-Spalten.**
Appwrite-Relationships sind nicht filter-/indizierbar der Top-N-Index auf
`attributes` (`brand_id, category_id, status, score DESC`) und alle Listen-Queries
brauchen aber genau das. Many-to-many (`videos.attribute_ids`) ist ein String-Array
`attribute_scores` (`brand_id, folder_id, category_id, score DESC`) und alle
Listen-Queries brauchen aber genau das. Many-to-many (`videos.attribute_ids`) ist ein String-Array
(`Query.contains`). IDs mit 64 Zeichen, weil uuid4 = 36 Zeichen.
- **`created_at`-Spalten entfallen** Appwrite pflegt `$createdAt` automatisch
(Indizes nutzen `$createdAt` direkt, z. B. `score_events`, `jobs`).

View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

43
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
example
# generated native folders
/ios
/android

1
client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
client/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

3
client/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

1
client/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

21
client/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
client/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
### Other setup steps
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

49
client/app.json Normal file
View File

@@ -0,0 +1,49 @@
{
"expo": {
"name": "BrandLoop",
"slug": "brandloop",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "brandloop",
"userInterfaceStyle": "dark",
"ios": {
"icon": "./assets/expo.icon"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0a0a0a",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"backgroundColor": "#0a0a0a",
"image": "./assets/images/splash-icon.png",
"imageWidth": 76
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"appwrite": {
"endpoint": "https://appwrite.webklar.com/v1",
"project": "6a5cee34002bb8360c34",
"databaseId": "brandloop"
}
}
}
}

View File

@@ -0,0 +1,3 @@
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -0,0 +1,40 @@
{
"fill" : {
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "expo-symbol 2.svg",
"name" : "expo-symbol 2",
"position" : {
"scale" : 1,
"translation-in-points" : [
1.1008400065293245e-05,
-16.046875
]
}
},
{
"image-name" : "grid.png",
"name" : "grid"
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

7949
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
client/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "client",
"main": "expo-router/entry",
"version": "1.0.0",
"dependencies": {
"@expo/ui": "~57.0.11",
"@expo/vector-icons": "^15.1.1",
"appwrite": "23.0.0",
"expo": "~57.0.13",
"expo-constants": "~57.0.11",
"expo-device": "~57.0.1",
"expo-font": "~57.0.1",
"expo-glass-effect": "~57.0.1",
"expo-image": "~57.0.3",
"expo-image-picker": "~57.0.10",
"expo-linking": "~57.0.6",
"expo-router": "~57.0.13",
"expo-splash-screen": "~57.0.6",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-system-ui": "~57.0.2",
"expo-web-browser": "~57.0.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
"react-native-appwrite": "0.25.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-url-polyfill": "^4.0.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.1"
},
"devDependencies": {
"@types/react": "~19.2.2",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint"
},
"private": true
}

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["src", "scripts"];
const exampleDir = "example";
const newAppDir = "src/app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View, StyleSheet } from "react-native";
export default function Index() {
return (
<View style={styles.container}>
<Text>Edit src/app/index.tsx to edit this screen.</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /src/app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /src/app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 src/app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 src/app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
userInput === "y"
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

View File

@@ -0,0 +1,20 @@
import { Redirect, Stack } from 'expo-router';
import { useSession } from '@/lib/session';
import { colors } from '@/theme/tokens';
export default function AuthLayout() {
const { laedt, konto } = useSession();
if (laedt) return null; // Splash bleibt stehen, bis die Session geklärt ist
if (konto) return <Redirect href="/" />;
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg },
}}
/>
);
}

View File

@@ -0,0 +1,79 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { anmelden, fehlertext } from '@/lib/auth';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function AnmeldenScreen() {
const router = useRouter();
const { neuLaden } = useSession();
const [email, setEmail] = useState('');
const [passwort, setPasswort] = useState('');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function absenden() {
setFehler(null);
setBusy(true);
try {
await anmelden(email.trim(), passwort);
await neuLaden();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe}>
<KeyboardAvoidingView
style={s.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Anmelden</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field
label="E-Mail"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder="du@marke.de"
/>
<Field
label="Passwort"
value={passwort}
onChangeText={setPasswort}
secureTextEntry
autoComplete="current-password"
/>
<View style={s.actions}>
<Button
title="Anmelden"
onPress={absenden}
busy={busy}
disabled={!email.includes('@') || passwort.length < 8}
/>
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
flex: { flex: 1 },
body: { padding: space.xl, gap: space.lg },
titel: { ...text.title, color: colors.txt },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,87 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { fehlertext, registrieren } from '@/lib/auth';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function RegistrierenScreen() {
const router = useRouter();
const { neuLaden } = useSession();
const [label, setLabel] = useState('');
const [email, setEmail] = useState('');
const [passwort, setPasswort] = useState('');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const bereit = label.trim().length > 1 && email.includes('@') && passwort.length >= 8;
async function absenden() {
setFehler(null);
setBusy(true);
try {
await registrieren(email.trim(), passwort, label.trim());
await neuLaden(); // Session-Zustand aktualisieren der Gate leitet dann selbst weiter
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe}>
<KeyboardAvoidingView
style={s.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Konto erstellen</Text>
<Text style={s.unter}>Ein Konto ist eine Marke. Der Name lässt sich später ändern.</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field
label="Name der Marke"
value={label}
onChangeText={setLabel}
autoCapitalize="words"
placeholder="z. B. Modaily"
/>
<Field
label="E-Mail"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
placeholder="du@marke.de"
/>
<Field
label="Passwort"
value={passwort}
onChangeText={setPasswort}
secureTextEntry
autoComplete="new-password"
placeholder="mindestens 8 Zeichen"
/>
<View style={s.actions}>
<Button title="Konto erstellen" onPress={absenden} busy={busy} disabled={!bereit} />
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
flex: { flex: 1 },
body: { padding: space.xl, gap: space.lg },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, marginTop: -space.sm },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,36 @@
import { useRouter } from 'expo-router';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { colors, space, text } from '@/theme/tokens';
export default function WillkommenScreen() {
const router = useRouter();
return (
<SafeAreaView style={s.safe}>
<View style={s.body}>
<View style={s.top}>
<Text style={s.marke}>BrandLoop</Text>
<Text style={s.claim}>
Deine Ads lernen aus jedem ausgegebenen Euro. Generische Tools erstellen Bilder hier
entsteht ein Gedächtnis für deine Marke.
</Text>
</View>
<View style={s.actions}>
<Button title="Konto erstellen" onPress={() => router.push('/registrieren')} />
<Button title="Anmelden" variant="ghost" onPress={() => router.push('/anmelden')} />
</View>
</View>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { flex: 1, justifyContent: 'space-between', padding: space.xl, paddingBottom: space.xxl },
top: { flex: 1, justifyContent: 'center', gap: space.lg },
marke: { fontSize: 40, fontWeight: '700', color: colors.txt, letterSpacing: -1 },
claim: { ...text.body, color: colors.mut, fontSize: 17, lineHeight: 25 },
actions: { gap: space.md },
});

View File

@@ -0,0 +1,65 @@
import { Ionicons } from '@expo/vector-icons';
import { Redirect, Tabs } from 'expo-router';
import { StyleSheet } from 'react-native';
import { useSession } from '@/lib/session';
import { colors, space } from '@/theme/tokens';
/**
* Drei Tabs: Feed · · Profil (app-aufbau.md §1 und §3).
*
* Das ist dort als **Popover** beschrieben und nicht als Screen es gibt
* drei verschiedene Erstellen-Abläufe, von denen keiner der Standard sein soll.
* Bis der Popover in E6 steht, ist es hier ein normaler Tab.
*/
export default function TabsLayout() {
const { laedt, konto } = useSession();
if (laedt) return null;
// Der Zugang wird hier nur *bequem* gesperrt. Dicht ist er durch die
// Zeilenrechte in Appwrite ohne Session liefert die API schlicht nichts.
if (!konto) return <Redirect href="/willkommen" />;
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.faint,
tabBarStyle: styles.bar,
tabBarLabelStyle: styles.label,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Feed',
tabBarIcon: ({ color, size }) => <Ionicons name="albums-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="erstellen"
options={{
title: 'Erstellen',
tabBarIcon: ({ color, size }) => <Ionicons name="add-circle-outline" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="profil"
options={{
title: 'Profil',
tabBarIcon: ({ color, size }) => <Ionicons name="person-outline" size={size} color={color} />,
}}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
bar: {
backgroundColor: colors.sheet,
borderTopColor: colors.border,
borderTopWidth: StyleSheet.hairlineWidth,
paddingTop: space.xs,
},
label: { fontSize: 11, fontWeight: '500' },
});

View File

@@ -0,0 +1,91 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Screen } from '@/components/screen';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
/**
* Wird in E6 zum Popover über der Tab-Leiste. Bis dahin dieselbe Auswahl als
* Screen die drei Abläufe sind unterschiedlich lang, keiner darf der
* Standard werden (app-aufbau.md §3).
*/
export default function ErstellenScreen() {
const router = useRouter();
const { aktiverOrdner } = useSession();
return (
<Screen title="Erstellen">
{aktiverOrdner ? (
<Text style={s.scope}>
Aktiver Ordner: <Text style={s.scopeName}>{aktiverOrdner.name}</Text>
</Text>
) : (
<Text style={s.scope}>Noch kein Ordner gewählt im Profil einen anlegen.</Text>
)}
<Eintrag
icon="images-outline"
titel="Post"
text="Kulisse, Produkt und Person wählen, Bilderkette erzeugen."
aufDruck={() => router.push('/erstellen/post')}
/>
<Eintrag icon="film-outline" titel="Video" text="Kommt mit E10." gesperrt />
<Eintrag
icon="cube-outline"
titel="Modell"
text="Person, Produkt oder Kulisse anlegen."
aufDruck={() => router.push('/modell/neu')}
/>
<Eintrag icon="document-outline" titel="Entwürfe" text="Kommt mit E6." gesperrt />
</Screen>
);
}
function Eintrag({
icon, titel, text: beschreibung, aufDruck, gesperrt,
}: {
icon: keyof typeof Ionicons.glyphMap;
titel: string;
text: string;
aufDruck?: () => void;
gesperrt?: boolean;
}) {
return (
<Pressable
onPress={aufDruck}
disabled={gesperrt}
accessibilityRole="button"
style={({ pressed }) => [s.zeile, gesperrt ? s.aus : null, pressed && !gesperrt ? s.gedrueckt : null]}>
<View style={s.icon}>
<Ionicons name={icon} size={20} color={gesperrt ? colors.faint : colors.accent} />
</View>
<View style={s.zeileText}>
<Text style={s.zeileTitel}>{titel}</Text>
<Text style={s.zeileUnter}>{beschreibung}</Text>
</View>
{!gesperrt ? <Ionicons name="chevron-forward" size={18} color={colors.faint} /> : null}
</Pressable>
);
}
const s = StyleSheet.create({
scope: { ...text.label, color: colors.mut },
scopeName: { color: colors.txt, fontWeight: '700' },
zeile: {
flexDirection: 'row', alignItems: 'center', gap: space.lg,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg,
},
aus: { opacity: 0.45 },
gedrueckt: { opacity: 0.8 },
icon: {
width: 38, height: 38, borderRadius: radius.sm,
backgroundColor: colors.surface2, alignItems: 'center', justifyContent: 'center',
},
zeileText: { flex: 1, gap: 2 },
zeileTitel: { ...text.body, fontWeight: '600', color: colors.txt },
zeileUnter: { ...text.label, color: colors.mut, lineHeight: 18 },
});

View File

@@ -0,0 +1,10 @@
import { Screen } from '@/components/screen';
export default function FeedScreen() {
return (
<Screen
title="Feed"
hint="E7 · Feed-Deck mit den Reitern Videos · Folge ich · Posts. Öffentliche Posts nach Nische gefiltert, nach feed_score sortiert „Folge ich“ dagegen chronologisch."
/>
);
}

View File

@@ -0,0 +1,208 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { TYPEN, assetsMitBild, type AssetMitBild, type AssetTyp } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { reifegrad, type Ordner } from '@/lib/folders';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function ProfilScreen() {
const router = useRouter();
const { brand, ordner, aktiverOrdner, setzeAktivenOrdner, ordnerNeuLaden, ausloggen } = useSession();
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [laedt, setLaedt] = useState(false);
const laden = useCallback(async () => {
if (!brand) return;
setLaedt(true);
try {
const [m] = await Promise.all([assetsMitBild(brand.$id), ordnerNeuLaden()]);
setModelle(m);
} finally {
setLaedt(false);
}
}, [brand, ordnerNeuLaden]);
// Nach dem Anlegen kommt man per router.back() zurück ohne das hier stünde
// die Liste noch auf dem alten Stand.
useFocusEffect(
useCallback(() => {
void laden();
}, [laden]),
);
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView
contentContainerStyle={s.body}
refreshControl={<RefreshControl refreshing={laedt} onRefresh={laden} tintColor={colors.mut} />}>
<Text style={s.marke}>{brand?.label_name ?? 'Profil'}</Text>
{/* Ordner stehen oben, nicht unter den Metriken sie sind der Zugang
zum Wissens-Scope (app-aufbau.md §4.5). */}
<Abschnitt
titel="Ordner"
aktion="Neu"
aufAktion={() => router.push('/ordner/neu')}
leer={ordner.length === 0 ? 'Noch kein Ordner. Der erste bestimmt, woraus generiert wird.' : undefined}>
{ordner.map((o) => (
<OrdnerZeile
key={o.$id}
ordner={o}
aktiv={o.$id === aktiverOrdner?.$id}
aufWahl={() => setzeAktivenOrdner(o)}
aufOeffnen={() => router.push(`/ordner/${o.$id}`)}
/>
))}
</Abschnitt>
<Abschnitt
titel="Modelle"
aktion="Neu"
aufAktion={() => router.push('/modell/neu')}
leer={modelle.length === 0 ? 'Noch kein Modell. Personen, Produkte und Kulissen kommen hierher.' : undefined}>
{TYPEN.map((t) => {
const davon = modelle.filter((m) => m.typ === t.wert);
if (!davon.length) return null;
return (
<View key={t.wert} style={s.gruppe}>
<Text style={s.gruppeTitel}>{t.titel}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
{davon.map((m) => (
<ModellKachel key={m.$id} modell={m} />
))}
</ScrollView>
</View>
);
})}
</Abschnitt>
<View style={s.fuss}>
<Text style={s.fussText}>{brand?.plan ?? 'trial'} · {brand?.$id}</Text>
<Button title="Abmelden" variant="ghost" onPress={() => void ausloggen()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
function Abschnitt({
titel, aktion, aufAktion, leer, children,
}: {
titel: string; aktion: string; aufAktion: () => void; leer?: string; children?: React.ReactNode;
}) {
return (
<View style={s.abschnitt}>
<View style={s.abschnittKopf}>
<Text style={s.abschnittTitel}>{titel}</Text>
<Pressable onPress={aufAktion} accessibilityRole="button" style={s.aktion}>
<Ionicons name="add" size={16} color={colors.accent} />
<Text style={s.aktionText}>{aktion}</Text>
</Pressable>
</View>
{leer ? <Text style={s.leer}>{leer}</Text> : children}
</View>
);
}
function OrdnerZeile({
ordner, aktiv, aufWahl, aufOeffnen,
}: {
ordner: Ordner; aktiv: boolean; aufWahl: () => void; aufOeffnen: () => void;
}) {
return (
<View style={[s.zeile, aktiv ? s.zeileAktiv : null]}>
{/* Antippen wählt, der Pfeil öffnet zwei verschiedene Absichten, die
man sonst nicht auseinanderhalten kann. */}
<Pressable
onPress={aufWahl}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
accessibilityLabel={`${ordner.name} als aktiven Ordner wählen`}
style={({ pressed }) => [s.zeileText, pressed ? s.gedrueckt : null]}>
<Text style={s.zeileTitel}>{ordner.name}</Text>
<Text style={s.zeileUnter}>
{ordner.zweck === 'sammlung' ? 'Sammlung' : 'Wissens-Scope'} · {reifegrad(ordner)}
</Text>
</Pressable>
{aktiv ? (
<View style={s.aktivChip}>
<Text style={s.aktivChipText}>aktiv</Text>
</View>
) : null}
<Pressable onPress={aufOeffnen} accessibilityRole="button" accessibilityLabel={`${ordner.name} öffnen`} hitSlop={8}>
<Ionicons name="chevron-forward" size={18} color={colors.faint} />
</Pressable>
</View>
);
}
function ModellKachel({ modell }: { modell: AssetMitBild }) {
const quelle = useBildQuelle(modell.titelbildId);
return (
<View style={s.kachel}>
{quelle ? (
<Image source={quelle} style={s.kachelBild} contentFit="cover" transition={150} />
) : (
<View style={[s.kachelBild, s.kachelLeer]}>
<Ionicons name={iconFuer(modell.typ)} size={22} color={colors.faint} />
</View>
)}
<Text style={s.kachelName} numberOfLines={1}>
{modell.name}
</Text>
</View>
);
}
function iconFuer(typ: AssetTyp): keyof typeof Ionicons.glyphMap {
return (TYPEN.find((t) => t.wert === typ)?.icon ?? 'cube-outline') as keyof typeof Ionicons.glyphMap;
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.xl, paddingBottom: space.xxl },
marke: { ...text.title, color: colors.txt },
abschnitt: { gap: space.md },
abschnittKopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
abschnittTitel: { ...text.heading, color: colors.txt },
aktion: { flexDirection: 'row', alignItems: 'center', gap: 2, paddingVertical: space.xs, paddingHorizontal: space.sm },
aktionText: { ...text.label, color: colors.accent, fontWeight: '600' },
leer: { ...text.body, color: colors.faint, lineHeight: 21 },
zeile: {
flexDirection: 'row', alignItems: 'center', gap: space.md,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg,
},
zeileAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.08)' },
gedrueckt: { opacity: 0.8 },
zeileText: { flex: 1, gap: 2 },
zeileTitel: { ...text.body, fontWeight: '600', color: colors.txt },
zeileUnter: { ...text.label, color: colors.mut },
aktivChip: { backgroundColor: colors.accent, borderRadius: radius.pill, paddingHorizontal: space.md, paddingVertical: 3 },
aktivChipText: { fontSize: 11, fontWeight: '700', color: '#fff' },
gruppe: { gap: space.sm },
gruppeTitel: { ...text.label, color: colors.faint, textTransform: 'uppercase', letterSpacing: 0.5 },
reihe: { gap: space.md, paddingRight: space.xl },
kachel: { width: 104, gap: space.sm },
kachelBild: {
width: 104, height: 104, borderRadius: radius.md,
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
kachelLeer: { alignItems: 'center', justifyContent: 'center' },
kachelName: { ...text.label, color: colors.mut },
fuss: { gap: space.md, marginTop: space.md },
fussText: { ...text.mono, color: colors.faint },
});

View File

@@ -0,0 +1,47 @@
import { DarkTheme, Stack, ThemeProvider } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider } from '@/lib/session';
import { colors } from '@/theme/tokens';
import '../global.css';
/**
* Wurzel-Navigation. Bewusst ein Stack und nicht direkt die Tabs: laut
* app-aufbau.md §3 liegen Willkommen/Anmelden **vor** den Tabs und das
* Onboarding als Modal **über** ihnen. Beide brauchen eine Ebene, auf der die
* Tab-Leiste nicht existiert die gibt es nur, wenn die Tabs eine Gruppe
* innerhalb eines Stacks sind.
*/
const navTheme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: colors.bg,
card: colors.sheet,
text: colors.txt,
border: colors.border,
primary: colors.accent,
},
};
export default function RootLayout() {
return (
<SafeAreaProvider>
<SessionProvider>
<ThemeProvider value={navTheme}>
<StatusBar style="light" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bg },
}}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(tabs)" />
{/* E3: onboarding als presentation: 'modal' */}
</Stack>
</ThemeProvider>
</SessionProvider>
</SafeAreaProvider>
);
}

View File

@@ -0,0 +1,214 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ModellWahl } from '@/components/modellwahl';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { assetsMitBild, type AssetMitBild } from '@/lib/assets';
import { fehlertext } from '@/lib/auth';
import { reifegrad } from '@/lib/folders';
import { FORMATE, postAnlegen, type Format, type Slots } from '@/lib/posts';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function PostErstellenScreen() {
const router = useRouter();
const { brand, ordner, aktiverOrdner, setzeAktivenOrdner } = useSession();
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [titel, setTitel] = useState('');
const [prompt, setPrompt] = useState('');
const [format, setFormat] = useState<Format>('4:5');
const [kette, setKette] = useState(3);
const [slots, setSlots] = useState<Slots>({});
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const laden = useCallback(async () => {
if (!brand) return;
setModelle(await assetsMitBild(brand.$id));
}, [brand]);
useEffect(() => {
void laden();
}, [laden]);
const setzeSlot = (k: keyof Slots) => (v: string | undefined) =>
setSlots((s) => ({ ...s, [k]: v }));
const bereit = !!aktiverOrdner && titel.trim().length > 1 && prompt.trim().length > 5;
async function anlegen() {
if (!brand?.team_id || !aktiverOrdner) return;
setFehler(null);
setBusy(true);
try {
await postAnlegen(brand.$id, brand.team_id, {
folderId: aktiverOrdner.$id,
titel: titel.trim(),
prompt: prompt.trim(),
format,
bildCount: kette,
slots,
nische: brand.nische,
});
router.replace(`/ordner/${aktiverOrdner.$id}`);
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Post erstellen</Text>
{/* Der aktive Ordner gehört sichtbar in die Kopfzeile, nicht in ein
Untermenü sonst wird im falschen Scope generiert (app-aufbau.md §2.2). */}
<View style={s.ordnerBox}>
<Text style={s.ordnerLabel}>Speichern in</Text>
{ordner.length === 0 ? (
<Text style={s.leer}>Noch kein Ordner. Erst im Profil einen anlegen.</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.chips}>
{ordner.map((o) => {
const aktiv = o.$id === aktiverOrdner?.$id;
return (
<Pressable
key={o.$id}
onPress={() => setzeAktivenOrdner(o)}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
style={[s.chip, aktiv ? s.chipAktiv : null]}>
<Text style={[s.chipText, aktiv ? s.chipTextAktiv : null]}>{o.name}</Text>
</Pressable>
);
})}
</ScrollView>
)}
{aktiverOrdner ? (
<Text style={s.ordnerHinweis}>
<Ionicons name="information-circle-outline" size={12} color={colors.faint} />{' '}
Zieht sein Wissen aus {aktiverOrdner.name}" · {reifegrad(aktiverOrdner)}
</Text>
) : null}
</View>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field label="Titel" value={titel} onChangeText={setTitel} placeholder="z. B. Serum auf Waschtisch" />
<ModellWahl
label="Kulisse"
typ="kulisse"
modelle={modelle}
gewaehlt={slots.kulisse_asset_id}
aufWahl={setzeSlot('kulisse_asset_id')}
hinweis="Wo das Bild spielt."
/>
<ModellWahl
label="Produkt"
typ="produkt"
modelle={modelle}
gewaehlt={slots.produkt_asset_id}
aufWahl={setzeSlot('produkt_asset_id')}
hinweis="Muss genau so aussehen wie im Regal."
/>
<ModellWahl
label="Person"
typ="gesicht"
modelle={modelle}
gewaehlt={slots.person_asset_id}
aufWahl={setzeSlot('person_asset_id')}
hinweis="Optional. Nur KI-generierte Gesichter echte brauchen eine dokumentierte Einwilligung."
/>
<Wahl
label="Format"
optionen={FORMATE.map((f) => ({ wert: f.wert, titel: f.titel, erklaerung: f.erklaerung }))}
wert={format}
aufWahl={setFormat}
/>
<View style={s.block}>
<Text style={s.label}>Bilder in der Kette</Text>
<Text style={s.hinweis}>
Über die Kette variieren nur Position und Winkel. Modelle, Kulisse, Licht und Farbe
bleiben gleich.
</Text>
<View style={s.zahlen}>
{[1, 2, 3, 4, 5].map((n) => (
<Pressable
key={n}
onPress={() => setKette(n)}
accessibilityRole="radio"
accessibilityState={{ selected: n === kette }}
style={[s.zahl, n === kette ? s.zahlAktiv : null]}>
<Text style={[s.zahlText, n === kette ? s.zahlTextAktiv : null]}>{n}</Text>
</Pressable>
))}
</View>
</View>
<Field
label="Was soll zu sehen sein?"
value={prompt}
onChangeText={setPrompt}
placeholder="Ein Satz genügt die Marke steuert den Rest bei."
multiline
numberOfLines={4}
style={s.mehrzeilig}
/>
<View style={s.actions}>
<Button title={`Rezept speichern und ${kette} Bilder einreihen`} onPress={anlegen} busy={busy} disabled={!bereit} />
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
ordnerBox: {
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg, gap: space.sm,
},
ordnerLabel: { ...text.label, color: colors.mut },
ordnerHinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
chips: { gap: space.sm, paddingRight: space.lg },
chip: {
borderRadius: radius.pill, paddingHorizontal: space.lg, paddingVertical: space.sm,
backgroundColor: colors.surface2, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
chipAktiv: { backgroundColor: colors.accent, borderColor: colors.accent },
chipText: { ...text.label, color: colors.mut },
chipTextAktiv: { color: '#fff', fontWeight: '700' },
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
leer: { ...text.label, color: colors.faint },
mehrzeilig: { minHeight: 96, textAlignVertical: 'top' },
zahlen: { flexDirection: 'row', gap: space.sm, marginTop: space.xs },
zahl: {
width: 46, height: 46, borderRadius: radius.sm,
alignItems: 'center', justifyContent: 'center',
backgroundColor: colors.surface, borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
zahlAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.12)' },
zahlText: { ...text.body, color: colors.mut, fontWeight: '600' },
zahlTextAktiv: { color: colors.txt },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,160 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { BILDER_EMPFOHLEN, BILDER_MAX, TYPEN, assetAnlegen, type AssetTyp } from '@/lib/assets';
import { fehlertext } from '@/lib/auth';
import { useSession } from '@/lib/session';
import type { Auswahl } from '@/lib/upload';
import { colors, radius, space, text } from '@/theme/tokens';
export default function ModellNeuScreen() {
const router = useRouter();
const { brand } = useSession();
const [typ, setTyp] = useState<AssetTyp>('gesicht');
const [name, setName] = useState('');
const [beschreibung, setBeschreibung] = useState('');
const [bilder, setBilder] = useState<Auswahl[]>([]);
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function bilderWaehlen() {
const res = await ImagePicker.launchImageLibraryAsync({
mediaTypes: 'images',
allowsMultipleSelection: true,
selectionLimit: BILDER_MAX - bilder.length,
quality: 0.9,
});
if (res.canceled) return;
const neu: Auswahl[] = res.assets.map((a, i) => ({
uri: a.uri,
name: a.fileName ?? `referenz-${Date.now()}-${i}.jpg`,
mimeType: a.mimeType ?? 'image/jpeg',
size: a.fileSize ?? 0,
}));
setBilder((b) => [...b, ...neu].slice(0, BILDER_MAX));
}
async function anlegen() {
if (!brand?.team_id) {
setFehler('Zur Marke ist kein Team hinterlegt bitte neu anmelden.');
return;
}
setFehler(null);
setBusy(true);
try {
await assetAnlegen(brand.$id, brand.team_id, {
typ,
name: name.trim(),
beschreibung: beschreibung.trim(),
bilder,
});
router.back();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
const zuViele = bilder.length > 7;
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Modell anlegen</Text>
<Text style={s.unter}>
Ein Modell ist das, was im Bild zu sehen ist eine Person, ein Produkt oder eine Kulisse.
Es wird wiederverwendet, damit es überall gleich aussieht.
</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Wahl label="Was ist es?" optionen={TYPEN} wert={typ} aufWahl={setTyp} />
<Field label="Name" value={name} onChangeText={setName} placeholder="z. B. Serum 30 ml" />
<Field
label="Beschreibung"
value={beschreibung}
onChangeText={setBeschreibung}
placeholder="Merkmale, die immer stimmen müssen wörtlich, nicht blumig."
multiline
numberOfLines={4}
style={s.mehrzeilig}
/>
<View style={s.block}>
<Text style={s.label}>Referenzbilder</Text>
<Text style={s.hinweis}>
{BILDER_EMPFOHLEN} bis {BILDER_MAX} sind das Optimum. Mehr als sieben mitteln die
Merkmale weg, statt sie zu schärfen.
</Text>
<View style={s.gitter}>
{bilder.map((b, i) => (
<View key={`${b.uri}-${i}`} style={s.kachel}>
<Image source={{ uri: b.uri }} style={s.vorschau} contentFit="cover" />
<Pressable
onPress={() => setBilder((alt) => alt.filter((_, j) => j !== i))}
style={s.weg}
accessibilityLabel="Bild entfernen">
<Ionicons name="close" size={14} color="#fff" />
</Pressable>
</View>
))}
{bilder.length < BILDER_MAX ? (
<Pressable onPress={bilderWaehlen} style={[s.kachel, s.plus]} accessibilityLabel="Bilder auswählen">
<Ionicons name="add" size={26} color={colors.mut} />
</Pressable>
) : null}
</View>
{zuViele ? <Text style={s.warnung}>Über sieben Bilder verschlechtern die Konsistenz.</Text> : null}
</View>
<View style={s.actions}>
<Button
title="Modell anlegen"
onPress={anlegen}
busy={busy}
disabled={name.trim().length < 2 || bilder.length === 0}
/>
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, lineHeight: 21, marginTop: -space.sm },
mehrzeilig: { minHeight: 100, textAlignVertical: 'top' },
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
gitter: { flexDirection: 'row', flexWrap: 'wrap', gap: space.md, marginTop: space.sm },
kachel: {
width: 88, height: 88, borderRadius: radius.sm, overflow: 'hidden',
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
vorschau: { width: '100%', height: '100%' },
weg: {
position: 'absolute', top: 4, right: 4,
width: 22, height: 22, borderRadius: 11,
backgroundColor: 'rgba(0,0,0,0.6)',
alignItems: 'center', justifyContent: 'center',
},
plus: { alignItems: 'center', justifyContent: 'center', borderStyle: 'dashed' },
warnung: { ...text.label, color: colors.gold },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,171 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button } from '@/components/ui';
import { assetsMitBild, type AssetMitBild } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { BUCKET_BILDER } from '@/lib/dateien';
import { ordnerLesen, reifegrad, type Ordner } from '@/lib/folders';
import { bilderZuPosts, postsImOrdner, slotsLesen, type Post, type PostBild } from '@/lib/posts';
import { useSession } from '@/lib/session';
import { colors, radius, space, text } from '@/theme/tokens';
export default function OrdnerDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { brand, aktiverOrdner, setzeAktivenOrdner } = useSession();
const [ordner, setOrdner] = useState<Ordner | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [modelle, setModelle] = useState<AssetMitBild[]>([]);
const [bilder, setBilder] = useState<Map<string, PostBild[]>>(new Map());
const laden = useCallback(async () => {
if (!id || !brand) return;
const [o, p, m] = await Promise.all([ordnerLesen(id), postsImOrdner(id), assetsMitBild(brand.$id)]);
setOrdner(o);
setPosts(p);
setModelle(m);
setBilder(await bilderZuPosts(p.map((x) => x.$id)));
}, [id, brand]);
useFocusEffect(
useCallback(() => {
void laden();
}, [laden]),
);
const istAktiv = ordner?.$id === aktiverOrdner?.$id;
const nameVon = (assetId?: string) => modelle.find((m) => m.$id === assetId)?.name;
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body}>
<Text style={s.titel}>{ordner?.name ?? 'Ordner'}</Text>
<Text style={s.unter}>
{ordner?.zweck === 'sammlung' ? 'Sammlung' : 'Wissens-Scope'} ·{' '}
{ordner?.startwert_modus === 'erben' ? 'geerbte Startwerte' : 'startet aus den Posts'} ·{' '}
{ordner ? reifegrad(ordner) : ''}
</Text>
{!istAktiv && ordner ? (
<Button title="Als aktiven Ordner setzen" variant="ghost" onPress={() => setzeAktivenOrdner(ordner)} />
) : (
<View style={s.aktivHinweis}>
<Ionicons name="checkmark-circle" size={15} color={colors.accent} />
<Text style={s.aktivText}>Aktiver Ordner neue Posts landen hier.</Text>
</View>
)}
<View style={s.abschnitt}>
<Text style={s.abschnittTitel}>Posts</Text>
{posts.length === 0 ? (
<Text style={s.leer}>Noch nichts drin. Ein Post entsteht über Erstellen.</Text>
) : (
posts.map((p) => {
const sl = slotsLesen(p);
const teile = [nameVon(sl.kulisse_asset_id), nameVon(sl.produkt_asset_id), nameVon(sl.person_asset_id)]
.filter(Boolean)
.join(' · ');
return (
<View key={p.$id} style={s.karte}>
<View style={s.karteKopf}>
<Text style={s.karteTitel} numberOfLines={1}>
{p.titel || 'Ohne Titel'}
</Text>
<View style={[s.status, p.status === 'generiert' ? s.statusOk : null]}>
<Text style={s.statusText}>{p.status === 'generiert' ? 'fertig' : 'wartet'}</Text>
</View>
</View>
<Text style={s.karteZeile}>
{p.format} · {p.bild_count} {p.bild_count === 1 ? 'Bild' : 'Bilder'} ·{' '}
{p.sichtbarkeit === 'oeffentlich' ? 'öffentlich' : 'privat'}
</Text>
{teile ? <Text style={s.karteSlots}>{teile}</Text> : null}
{p.user_prompt ? (
<Text style={s.kartePrompt} numberOfLines={2}>
{p.user_prompt}
</Text>
) : null}
{(bilder.get(p.$id) ?? []).length > 0 ? (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.kette}>
{(bilder.get(p.$id) ?? []).map((b) => (
<KettenBild key={b.$id} bild={b} />
))}
</ScrollView>
) : null}
</View>
);
})
)}
</View>
<Button title="Zurück" variant="ghost" onPress={() => router.back()} />
</ScrollView>
</SafeAreaView>
);
}
function KettenBild({ bild }: { bild: PostBild }) {
const quelle = useBildQuelle(bild.storage_file_id, BUCKET_BILDER);
return (
<View style={s.kettenRahmen}>
{quelle ? (
<Image source={quelle} style={s.kettenBild} contentFit="cover" transition={150} />
) : (
<View style={[s.kettenBild, s.kettenLeer]}>
<Ionicons name="hourglass-outline" size={16} color={colors.faint} />
</View>
)}
<View style={s.kettenNr}>
<Text style={s.kettenNrText}>{bild.position}</Text>
</View>
</View>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
kette: { gap: space.sm, paddingTop: space.sm, paddingRight: space.lg },
kettenRahmen: {
width: 76, height: 95, borderRadius: radius.sm, overflow: 'hidden',
backgroundColor: colors.surface2,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
},
kettenBild: { width: '100%', height: '100%' },
kettenLeer: { alignItems: 'center', justifyContent: 'center' },
kettenNr: {
position: 'absolute', bottom: 4, left: 4,
minWidth: 16, height: 16, borderRadius: 8, paddingHorizontal: 4,
backgroundColor: 'rgba(0,0,0,0.65)', alignItems: 'center', justifyContent: 'center',
},
kettenNrText: { fontSize: 10, color: '#fff', fontWeight: '700' },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.label, color: colors.mut, marginTop: -space.sm },
aktivHinweis: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
aktivText: { ...text.label, color: colors.mut },
abschnitt: { gap: space.md },
abschnittTitel: { ...text.heading, color: colors.txt },
leer: { ...text.body, color: colors.faint },
karte: {
backgroundColor: colors.surface,
borderColor: colors.border, borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md, padding: space.lg, gap: space.xs,
},
karteKopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.md },
karteTitel: { ...text.body, fontWeight: '600', color: colors.txt, flexShrink: 1 },
status: { backgroundColor: colors.surface2, borderRadius: radius.pill, paddingHorizontal: space.md, paddingVertical: 2 },
statusOk: { backgroundColor: 'rgba(52,211,153,0.18)' },
statusText: { fontSize: 11, color: colors.mut, fontWeight: '600' },
karteZeile: { ...text.label, color: colors.mut },
karteSlots: { ...text.label, color: colors.faint },
kartePrompt: { ...text.label, color: colors.faint, lineHeight: 18, marginTop: space.xs },
});

View File

@@ -0,0 +1,88 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Button, ErrorNote, Field } from '@/components/ui';
import { Wahl } from '@/components/wahl';
import { fehlertext } from '@/lib/auth';
import { ordnerAnlegen, STARTWERTE, ZWECKE, type Startwert, type Zweck } from '@/lib/folders';
import { useSession } from '@/lib/session';
import { colors, space, text } from '@/theme/tokens';
export default function OrdnerNeuScreen() {
const router = useRouter();
const { brand, ordnerNeuLaden, setzeAktivenOrdner } = useSession();
const [name, setName] = useState('');
const [thema, setThema] = useState('');
const [zweck, setZweck] = useState<Zweck>('wissens_scope');
const [startwert, setStartwert] = useState<Startwert>('erben');
const [fehler, setFehler] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function anlegen() {
if (!brand?.team_id) {
setFehler('Zur Marke ist kein Team hinterlegt bitte neu anmelden.');
return;
}
setFehler(null);
setBusy(true);
try {
const o = await ordnerAnlegen(brand.$id, brand.team_id, {
name: name.trim(),
zweck,
startwert_modus: startwert,
theme_md: thema.trim(),
});
await ordnerNeuLaden();
setzeAktivenOrdner(o);
router.back();
} catch (e) {
setFehler(fehlertext(e));
setBusy(false);
}
}
return (
<SafeAreaView style={s.safe} edges={['top', 'left', 'right']}>
<ScrollView contentContainerStyle={s.body} keyboardShouldPersistTaps="handled">
<Text style={s.titel}>Ordner anlegen</Text>
<Text style={s.unter}>
Ein Ordner ist ein eigener Wissensstand, keine Ablage. Was hier hineinkommt, prägt die
Bilder, die daraus entstehen und nur die.
</Text>
{fehler ? <ErrorNote message={fehler} /> : null}
<Field label="Name" value={name} onChangeText={setName} placeholder="z. B. Sommerkampagne" />
<Field
label="Worum geht es hier? (optional)"
value={thema}
onChangeText={setThema}
placeholder="Hilft später beim Einsortieren"
multiline
numberOfLines={3}
style={s.mehrzeilig}
/>
<Wahl label="Zweck" optionen={ZWECKE} wert={zweck} aufWahl={setZweck} />
<Wahl label="Startwerte" optionen={STARTWERTE} wert={startwert} aufWahl={setStartwert} />
<View style={s.actions}>
<Button title="Ordner anlegen" onPress={anlegen} busy={busy} disabled={name.trim().length < 2} />
<Button title="Abbrechen" variant="ghost" onPress={() => router.back()} />
</View>
</ScrollView>
</SafeAreaView>
);
}
const s = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { padding: space.xl, gap: space.lg, paddingBottom: space.xxl },
titel: { ...text.title, color: colors.txt },
unter: { ...text.body, color: colors.mut, lineHeight: 21, marginTop: -space.sm },
mehrzeilig: { minHeight: 84, textAlignVertical: 'top' },
actions: { gap: space.md, marginTop: space.md },
});

View File

@@ -0,0 +1,106 @@
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { TYPEN, type AssetMitBild, type AssetTyp } from '@/lib/assets';
import { useBildQuelle } from '@/lib/bildquelle';
import { colors, radius, space, text } from '@/theme/tokens';
type Props = {
label: string;
typ: AssetTyp;
modelle: AssetMitBild[];
gewaehlt?: string;
aufWahl: (id: string | undefined) => void;
hinweis?: string;
};
/**
* Auswahl eines eigenen Modells für einen Slot als Bildkachel, nicht als
* Dropdown. Bei Kulissen und Produkten ist das Aussehen die Information; ein
* Name wie „Halle, Metallwand" sagt nichts darüber, ob es passt.
*/
export function ModellWahl({ label, typ, modelle, gewaehlt, aufWahl, hinweis }: Props) {
const passende = modelle.filter((m) => m.typ === typ);
const titel = TYPEN.find((t) => t.wert === typ)?.titel ?? label;
return (
<View style={s.block}>
<View style={s.kopf}>
<Text style={s.label}>{label}</Text>
{gewaehlt ? (
<Pressable onPress={() => aufWahl(undefined)} accessibilityRole="button">
<Text style={s.loeschen}>entfernen</Text>
</Pressable>
) : null}
</View>
{hinweis ? <Text style={s.hinweis}>{hinweis}</Text> : null}
{passende.length === 0 ? (
<Text style={s.leer}>Noch kein Modell vom Typ {titel}". Erst im Profil anlegen.</Text>
) : (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={s.reihe}>
{passende.map((m) => (
<Kachel key={m.$id} modell={m} aktiv={m.$id === gewaehlt} aufWahl={() => aufWahl(m.$id)} />
))}
</ScrollView>
)}
</View>
);
}
function Kachel({ modell, aktiv, aufWahl }: { modell: AssetMitBild; aktiv: boolean; aufWahl: () => void }) {
const quelle = useBildQuelle(modell.titelbildId);
return (
<Pressable
onPress={aufWahl}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
accessibilityLabel={modell.name}
style={({ pressed }) => [s.kachel, pressed ? s.gedrueckt : null]}>
<View style={[s.rahmen, aktiv ? s.rahmenAktiv : null]}>
{quelle ? (
<Image source={quelle} style={s.bild} contentFit="cover" transition={150} />
) : (
<View style={[s.bild, s.bildLeer]}>
<Ionicons name="image-outline" size={20} color={colors.faint} />
</View>
)}
{aktiv ? (
<View style={s.haken}>
<Ionicons name="checkmark" size={13} color="#fff" />
</View>
) : null}
</View>
<Text style={[s.name, aktiv ? s.nameAktiv : null]} numberOfLines={1}>
{modell.name}
</Text>
</Pressable>
);
}
const s = StyleSheet.create({
block: { gap: space.sm },
kopf: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
label: { ...text.label, color: colors.mut },
loeschen: { ...text.label, color: colors.faint },
hinweis: { ...text.label, color: colors.faint, lineHeight: 18 },
leer: { ...text.label, color: colors.faint, lineHeight: 18, paddingVertical: space.sm },
reihe: { gap: space.md, paddingRight: space.xl, paddingTop: space.xs },
kachel: { width: 92, gap: space.xs },
gedrueckt: { opacity: 0.8 },
rahmen: {
width: 92, height: 92, borderRadius: radius.md, overflow: 'hidden',
borderColor: colors.border, borderWidth: 2, backgroundColor: colors.surface,
},
rahmenAktiv: { borderColor: colors.accent },
bild: { width: '100%', height: '100%' },
bildLeer: { alignItems: 'center', justifyContent: 'center' },
haken: {
position: 'absolute', top: 5, right: 5,
width: 20, height: 20, borderRadius: 10, backgroundColor: colors.accent,
alignItems: 'center', justifyContent: 'center',
},
name: { ...text.label, color: colors.faint },
nameAktiv: { color: colors.txt },
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, radius, space, text } from '@/theme/tokens';
type ScreenProps = {
title: string;
/** Kurzer Hinweis, was hier später entsteht nur solange der Screen leer ist. */
hint?: string;
children?: ReactNode;
};
/**
* Gemeinsames Grundgerüst aller Screens: Hintergrund, sichere Ränder, Titel.
* In E1 tragen die drei Tabs damit nur ihren Namen ab E3 kommt Inhalt in
* `children`, der Rahmen bleibt.
*/
export function Screen({ title, hint, children }: ScreenProps) {
return (
<SafeAreaView style={styles.safe} edges={['top', 'left', 'right']}>
<View style={styles.body}>
<Text style={styles.title}>{title}</Text>
{hint ? (
<View style={styles.hintBox}>
<Text style={styles.hint}>{hint}</Text>
</View>
) : null}
{children}
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg },
body: { flex: 1, paddingHorizontal: space.xl, paddingTop: space.xl, gap: space.lg },
title: { ...text.title, color: colors.txt },
hintBox: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
padding: space.lg,
},
hint: { ...text.body, color: colors.mut, lineHeight: 21 },
});

View File

@@ -0,0 +1,114 @@
import { forwardRef } from 'react';
import {
ActivityIndicator,
Pressable,
StyleSheet,
Text,
TextInput,
View,
type TextInputProps,
} from 'react-native';
import { colors, radius, space, text } from '@/theme/tokens';
type FieldProps = TextInputProps & { label: string; error?: string };
export const Field = forwardRef<TextInput, FieldProps>(function Field(
{ label, error, style, ...props },
ref,
) {
return (
<View style={s.field}>
<Text style={s.label}>{label}</Text>
<TextInput
ref={ref}
placeholderTextColor={colors.faint}
style={[s.input, error ? s.inputError : null, style]}
{...props}
/>
{error ? <Text style={s.error}>{error}</Text> : null}
</View>
);
});
type ButtonProps = {
title: string;
onPress: () => void;
variant?: 'primary' | 'ghost';
busy?: boolean;
disabled?: boolean;
};
export function Button({ title, onPress, variant = 'primary', busy, disabled }: ButtonProps) {
const off = disabled || busy;
return (
<Pressable
onPress={onPress}
disabled={off}
accessibilityRole="button"
style={({ pressed }) => [
s.btn,
variant === 'primary' ? s.btnPrimary : s.btnGhost,
pressed && !off ? s.btnPressed : null,
off ? s.btnOff : null,
]}>
{busy ? (
<ActivityIndicator color={variant === 'primary' ? '#fff' : colors.txt} />
) : (
<Text style={[s.btnText, variant === 'ghost' ? s.btnTextGhost : null]}>{title}</Text>
)}
</Pressable>
);
}
/** Fehlermeldung aus einem fehlgeschlagenen Aufruf nie stumm scheitern lassen. */
export function ErrorNote({ message }: { message: string }) {
return (
<View style={s.errBox}>
<Text style={s.errText}>{message}</Text>
</View>
);
}
const s = StyleSheet.create({
field: { gap: space.sm },
label: { ...text.label, color: colors.mut },
input: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.sm,
paddingHorizontal: space.lg,
paddingVertical: space.md,
color: colors.txt,
fontSize: 16,
},
inputError: { borderColor: colors.accent },
error: { ...text.label, color: colors.accent },
btn: {
borderRadius: radius.pill,
paddingVertical: space.lg,
paddingHorizontal: space.xl,
alignItems: 'center',
justifyContent: 'center',
minHeight: 52,
},
btnPrimary: { backgroundColor: colors.accent },
btnGhost: {
backgroundColor: 'transparent',
borderColor: colors.borderStrong,
borderWidth: StyleSheet.hairlineWidth,
},
btnPressed: { opacity: 0.75 },
btnOff: { opacity: 0.45 },
btnText: { ...text.body, fontWeight: '600', color: '#fff', fontSize: 16 },
btnTextGhost: { color: colors.txt },
errBox: {
backgroundColor: 'rgba(255,92,57,0.12)',
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.sm,
padding: space.lg,
},
errText: { ...text.body, color: colors.txt },
});

View File

@@ -0,0 +1,75 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors, radius, space, text } from '@/theme/tokens';
type Option<T extends string> = { wert: T; titel: string; erklaerung: string };
type Props<T extends string> = {
label: string;
optionen: Option<T>[];
wert: T;
aufWahl: (w: T) => void;
};
/**
* Auswahl mit Erklärung je Option statt eines nackten Schalters.
*
* Bewusst so: „Zweck" und „Startwerte" beim Ordner entscheiden darüber, woraus
* später generiert wird wer sie nicht versteht, baut sich einen Ordner, der
* nicht das tut, was er erwartet (konzept-bilder-feed.md §9).
*/
export function Wahl<T extends string>({ label, optionen, wert, aufWahl }: Props<T>) {
return (
<View style={s.block}>
<Text style={s.label}>{label}</Text>
<View style={s.optionen}>
{optionen.map((o) => {
const aktiv = o.wert === wert;
return (
<Pressable
key={o.wert}
onPress={() => aufWahl(o.wert)}
accessibilityRole="radio"
accessibilityState={{ selected: aktiv }}
style={({ pressed }) => [s.opt, aktiv ? s.optAktiv : null, pressed ? s.gedrueckt : null]}>
<View style={s.kopf}>
<View style={[s.punkt, aktiv ? s.punktAktiv : null]}>
{aktiv ? <View style={s.punktKern} /> : null}
</View>
<Text style={[s.titel, aktiv ? s.titelAktiv : null]}>{o.titel}</Text>
</View>
<Text style={s.erklaerung}>{o.erklaerung}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
const s = StyleSheet.create({
block: { gap: space.sm },
label: { ...text.label, color: colors.mut },
optionen: { gap: space.sm },
opt: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: radius.md,
padding: space.lg,
gap: space.sm,
},
optAktiv: { borderColor: colors.accent, backgroundColor: 'rgba(255,92,57,0.08)' },
gedrueckt: { opacity: 0.8 },
kopf: { flexDirection: 'row', alignItems: 'center', gap: space.md },
punkt: {
width: 18, height: 18, borderRadius: 9,
borderColor: colors.borderStrong, borderWidth: 1.5,
alignItems: 'center', justifyContent: 'center',
},
punktAktiv: { borderColor: colors.accent },
punktKern: { width: 9, height: 9, borderRadius: 5, backgroundColor: colors.accent },
titel: { ...text.body, fontWeight: '600', color: colors.txt },
titelAktiv: { color: colors.txt },
erklaerung: { ...text.label, color: colors.mut, lineHeight: 18 },
});

15
client/src/global.css Normal file
View File

@@ -0,0 +1,15 @@
/* Nur für den Web-Build. Font-Stack und Grundfarbe wie in prototyp-app.html,
damit die Seite beim Laden nicht kurz weiß aufblitzt. */
:root {
--font-display:
-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Inter, Roboto, system-ui, sans-serif;
--font-mono: ui-monospace, Menlo, SFMono-Regular, Consolas, monospace;
}
html,
body,
#root {
background-color: #0a0a0a;
color: #ffffff;
font-family: var(--font-display);
}

View File

@@ -0,0 +1,32 @@
/**
* Appwrite-Client **native** (iOS/Android).
*
* Es gibt zwei Fassungen dieser Datei: diese und `appwrite.web.ts`. Metro löst
* die Endung nach Plattform auf, der Rest der App importiert immer nur
* `@/lib/appwrite` und merkt vom Unterschied nichts.
*
* Warum überhaupt zwei: `react-native-appwrite` bringt native Abhängigkeiten
* mit (URL-Polyfill, Dateizugriff) und läuft nicht im Browser; das Web-SDK
* `appwrite` kennt umgekehrt kein React Native. Beide exportieren dieselben
* Klassen, deshalb ist die Trennung hier billig und wäre später teuer.
*/
import 'react-native-url-polyfill/auto';
import { Account, Client, Storage, TablesDB, Teams } from 'react-native-appwrite';
import { appwriteConfig } from './config';
export const client = new Client()
.setEndpoint(appwriteConfig.endpoint)
.setProject(appwriteConfig.project);
// TODO (E2): .setPlatform('<bundle-id>') sobald die native App-Kennung feststeht
// und in der Appwrite-Konsole als Platform registriert ist. Ohne das lehnt
// Appwrite Anfragen aus dem nativen Build ab.
export const account = new Account(client);
export const tables = new TablesDB(client);
export const storage = new Storage(client);
export const teams = new Teams(client);
export { ID, Permission, Query, Role } from 'react-native-appwrite';
export const databaseId = appwriteConfig.databaseId;

View File

@@ -0,0 +1,22 @@
/**
* Appwrite-Client **Web**. Gegenstück zu `appwrite.ts`, siehe dortiger Kopf.
*
* Die Web-App ist nicht nur ein Nebenprodukt: die Zahlung soll laut
* projekt-uebersicht.md §9 bewusst hier laufen, um die App-Store-Abgabe zu
* umgehen. Dieser Pfad muss also genauso funktionieren wie der native.
*/
import { Account, Client, Storage, TablesDB, Teams } from 'appwrite';
import { appwriteConfig } from './config';
export const client = new Client()
.setEndpoint(appwriteConfig.endpoint)
.setProject(appwriteConfig.project);
export const account = new Account(client);
export const tables = new TablesDB(client);
export const storage = new Storage(client);
export const teams = new Teams(client);
export { ID, Permission, Query, Role } from 'appwrite';
export const databaseId = appwriteConfig.databaseId;

154
client/src/lib/assets.ts Normal file
View File

@@ -0,0 +1,154 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { BUCKET_REFERENZEN as BUCKET } from './dateien';
import { teamRechte } from './permissions';
import { hochladen, type Auswahl } from './upload';
/**
* „Modell“ heißt in diesem Projekt **immer** das Asset Person, Produkt,
* Kulisse oder Logo. Das generierende KI-Modell heißt ausgeschrieben
* (projekt-uebersicht.md, Sprachregelung).
*/
export type AssetTyp = 'gesicht' | 'produkt' | 'kulisse' | 'logo' | 'sonstiges';
export type Asset = {
$id: string;
brand_id: string;
typ: AssetTyp;
name: string;
released_version_id?: string;
ist_teilbar?: boolean;
};
export type AssetVersion = {
$id: string;
asset_id: string;
version_no: number;
status: 'entwurf' | 'freigegeben' | 'archiviert';
beschreibung_md?: string;
merkmale?: string[];
reference_file_ids?: string[];
};
export const TYPEN: { wert: AssetTyp; titel: string; erklaerung: string; icon: string }[] = [
{ wert: 'gesicht', titel: 'Person', erklaerung: 'Ein Gesicht, das in Bildern wiederkehrt.', icon: 'person-outline' },
{ wert: 'produkt', titel: 'Produkt', erklaerung: 'Ein Objekt, das genau so aussehen muss wie im Regal.', icon: 'cube-outline' },
{ wert: 'kulisse', titel: 'Kulisse', erklaerung: 'Ein Ort oder Hintergrund, vor dem gearbeitet wird.', icon: 'image-outline' },
{ wert: 'logo', titel: 'Logo', erklaerung: 'Markenzeichen, das nie verfremdet werden darf.', icon: 'ribbon-outline' },
];
export { BUCKET_REFERENZEN, dateiUrl } from './dateien';
export async function assetListe(brandId: string, typ?: AssetTyp): Promise<Asset[]> {
const queries = [Query.equal('brand_id', brandId), Query.limit(100)];
if (typ) queries.push(Query.equal('typ', typ));
const res = await tables.listRows({ databaseId, tableId: 'assets', queries });
return res.rows as unknown as Asset[];
}
export async function versionLesen(id: string): Promise<AssetVersion | null> {
try {
const row = await tables.getRow({ databaseId, tableId: 'asset_versions', rowId: id });
return row as unknown as AssetVersion;
} catch {
return null;
}
}
export async function versionenZuAsset(assetId: string): Promise<AssetVersion[]> {
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
queries: [Query.equal('asset_id', assetId), Query.limit(50)],
});
return res.rows as unknown as AssetVersion[];
}
/**
* Legt ein Modell samt erster Version an und gibt sie sofort frei.
*
* Release-Prinzip (projekt-uebersicht.md §4): Bilder verwenden immer eine
* *freigegebene* Version, damit nichts unbemerkt wegdriftet. Version 1 wird
* hier direkt freigegeben, weil es sonst nichts gäbe, womit man arbeiten kann.
*/
export async function assetAnlegen(
brandId: string,
teamId: string,
daten: { typ: AssetTyp; name: string; beschreibung: string; bilder: Auswahl[] },
): Promise<Asset> {
const rechte = teamRechte(teamId);
const fileIds: string[] = [];
for (const bild of daten.bilder) {
fileIds.push(await hochladen(BUCKET, bild, rechte));
}
const asset = (await tables.createRow({
databaseId,
tableId: 'assets',
rowId: ID.unique(),
data: { brand_id: brandId, typ: daten.typ, name: daten.name, ist_teilbar: false },
permissions: rechte,
})) as unknown as Asset;
const version = (await tables.createRow({
databaseId,
tableId: 'asset_versions',
rowId: ID.unique(),
data: {
asset_id: asset.$id,
version_no: 1,
status: 'freigegeben',
beschreibung_md: daten.beschreibung,
reference_file_ids: fileIds,
},
permissions: rechte,
})) as unknown as AssetVersion;
await tables.updateRow({
databaseId,
tableId: 'assets',
rowId: asset.$id,
data: { released_version_id: version.$id },
});
return { ...asset, released_version_id: version.$id };
}
export async function assetLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'assets', rowId: id });
}
export type AssetMitBild = Asset & { titelbildId?: string };
/**
* Modelle samt Titelbild das erste Referenzbild der **freigegebenen** Version.
*
* Holt alle Versionen in einer einzigen Abfrage statt einer je Modell:
* `Query.equal` nimmt auch eine Liste. Bei 30 Kulissen wären das sonst 30
* Rundreisen, nur um Vorschaubilder zu zeigen.
*/
export async function assetsMitBild(brandId: string, typ?: AssetTyp): Promise<AssetMitBild[]> {
const liste = await assetListe(brandId, typ);
const versionIds = liste.map((a) => a.released_version_id).filter((v): v is string => !!v);
if (!versionIds.length) return liste;
const res = await tables.listRows({
databaseId,
tableId: 'asset_versions',
queries: [Query.equal('$id', versionIds), Query.limit(100)],
});
const nachId = new Map(
(res.rows as unknown as AssetVersion[]).map((v) => [v.$id, v.reference_file_ids?.[0]]),
);
return liste.map((a) => ({
...a,
titelbildId: a.released_version_id ? nachId.get(a.released_version_id) : undefined,
}));
}
/**
* Mehr als 7 Referenzbilder mitteln die Merkmale weg („feature averaging“),
* 46 sind das Optimum (projekt-uebersicht.md §8).
*/
export const BILDER_MAX = 6;
export const BILDER_EMPFOHLEN = 4;

88
client/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,88 @@
import { ID, Permission, Query, Role, account, databaseId, tables, teams } from './appwrite';
/** Eine Zeile aus `brands`, so weit die App sie braucht. */
export type Brand = {
$id: string;
team_id?: string;
label_name: string;
anzeigename?: string;
nische?: string;
plan?: string;
status?: string;
};
export type Konto = { $id: string; email: string; name: string };
export async function aktuellesKonto(): Promise<Konto | null> {
try {
const u = await account.get();
return { $id: u.$id, email: u.email, name: u.name };
} catch {
return null; // keine Session kein Fehlerfall, sondern der Normalzustand vor dem Login
}
}
/**
* Die Brand des angemeldeten Kontos.
*
* Es braucht hier bewusst **keinen** Filter auf die eigene ID: `brands` hat
* `rowSecurity`, und die Zeile trägt nur die Team-Permission ihrer Brand. Die
* Abfrage liefert deshalb von sich aus ausschließlich die eigene Zeile. Genau
* das ist die Mandantentrennung sie steckt in den Rechten, nicht im Query.
*/
export async function eigeneBrand(): Promise<Brand | null> {
const res = await tables.listRows({
databaseId,
tableId: 'brands',
queries: [Query.limit(1)],
});
return (res.rows[0] as unknown as Brand) ?? null;
}
/**
* Legt Team + `brands`-Zeile an, falls beides noch fehlt.
*
* Eigene Funktion, weil die Registrierung aus vier Schritten besteht und
* zwischen Schritt 2 und 4 abbrechen kann (Netz weg, App geschlossen). Dann
* existiert ein Konto ohne Brand. Statt diesen Zustand als Fehler zu behandeln,
* wird er beim nächsten Start einfach nachgeholt.
*/
export async function brandSicherstellen(labelName: string): Promise<Brand> {
const vorhanden = await eigeneBrand();
if (vorhanden) return vorhanden;
const meine = await teams.list({ queries: [Query.limit(1)] });
const team = meine.teams[0] ?? (await teams.create({ teamId: ID.unique(), name: labelName }));
const rolle = Role.team(team.$id);
const row = await tables.createRow({
databaseId,
tableId: 'brands',
rowId: ID.unique(),
data: { team_id: team.$id, label_name: labelName, status: 'trial', plan: 'trial' },
// Kein create() die Zeile existiert ja bereits. Lesen/Ändern/Löschen
// ausschließlich für das Team dieser Brand.
permissions: [Permission.read(rolle), Permission.update(rolle), Permission.delete(rolle)],
});
return row as unknown as Brand;
}
export async function registrieren(email: string, password: string, labelName: string): Promise<Brand> {
await account.create({ userId: ID.unique(), email, password, name: labelName });
await account.createEmailPasswordSession({ email, password });
return brandSicherstellen(labelName);
}
export async function anmelden(email: string, password: string): Promise<void> {
await account.createEmailPasswordSession({ email, password });
}
export async function abmelden(): Promise<void> {
await account.deleteSession({ sessionId: 'current' });
}
/** Appwrite-Fehler tragen die Meldung in `message`; alles andere abfangen. */
export function fehlertext(e: unknown): string {
if (e && typeof e === 'object' && 'message' in e) return String((e as { message: unknown }).message);
return 'Unbekannter Fehler.';
}

View File

@@ -0,0 +1,16 @@
import { BUCKET_REFERENZEN, dateiUrl } from './dateien';
/**
* Bildquelle für ein geschütztes Appwrite-File **native**.
* Gegenstück: `bildquelle.web.ts`.
*
* Nativ teilen sich `<Image>` und die SDK-Aufrufe den HTTP-Stack der
* Plattform samt Cookie-Speicher, und die Same-Site-Regeln des Browsers gelten
* nicht. Die schlichte URL genügt hier also.
*/
export function useBildQuelle(
fileId?: string,
bucketId: string = BUCKET_REFERENZEN,
): { uri: string } | undefined {
return fileId ? { uri: dateiUrl(fileId, bucketId) } : undefined;
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { BUCKET_REFERENZEN, dateiUrl } from './dateien';
/**
* Bildquelle für ein geschütztes Appwrite-File **Web**.
* Gegenstück: `bildquelle.ts`.
*
* Warum nicht einfach die URL ins `<img>`: Ein `<img>` schickt bei einer
* site-fremden Anfrage die Appwrite-Session nicht mit, das Bild bleibt leer.
* Im Betrieb liegen App und Appwrite unter derselben Domain (`webklar.com`),
* dort wäre das kein Thema in der Entwicklung läuft die App aber auf
* `localhost`, und dann ist jede Anfrage site-fremd.
*
* Deshalb wird die Datei einmal per `fetch` mit `credentials: 'include'`
* geholt und als Object-URL gerendert. Das funktioniert in beiden Fällen und
* spart die Sonderbehandlung „nur lokal kaputt".
*/
export function useBildQuelle(
fileId?: string,
bucketId: string = BUCKET_REFERENZEN,
): { uri: string } | undefined {
const [uri, setUri] = useState<string>();
useEffect(() => {
if (!fileId) {
setUri(undefined);
return;
}
let abgebrochen = false;
let objectUrl: string | undefined;
fetch(dateiUrl(fileId, bucketId), { credentials: 'include' })
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((blob) => {
if (abgebrochen) return;
objectUrl = URL.createObjectURL(blob);
setUri(objectUrl);
})
.catch(() => {
if (!abgebrochen) setUri(undefined);
});
return () => {
abgebrochen = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [fileId, bucketId]);
return uri ? { uri } : undefined;
}

35
client/src/lib/config.ts Normal file
View File

@@ -0,0 +1,35 @@
import Constants from 'expo-constants';
/**
* Client-Konfiguration aus `app.json` → `expo.extra.appwrite`.
*
* ⚠️ Hier gehört ausschließlich hinein, was ohnehin im Bundle landet und
* öffentlich sein darf: Endpoint, Projekt-ID, Datenbank-ID. Der Appwrite-
* **Server-API-Key** darf niemals in den Client er steht in der .env im
* Repo-Root und wird nur von den Skripten in `scripts/` und später von
* Appwrite-Functions benutzt.
*/
type AppwriteConfig = {
endpoint: string;
project: string;
databaseId: string;
};
const extra = Constants.expoConfig?.extra as { appwrite?: Partial<AppwriteConfig> } | undefined;
const cfg = extra?.appwrite;
function required(key: keyof AppwriteConfig): string {
const value = cfg?.[key];
if (!value) {
throw new Error(
`Appwrite-Konfiguration unvollständig: "${key}" fehlt in app.json unter expo.extra.appwrite.`,
);
}
return value;
}
export const appwriteConfig: AppwriteConfig = {
endpoint: required('endpoint'),
project: required('project'),
databaseId: required('databaseId'),
};

18
client/src/lib/dateien.ts Normal file
View File

@@ -0,0 +1,18 @@
import { appwriteConfig } from './config';
export const BUCKET_REFERENZEN = 'asset-references';
export const BUCKET_UPLOADS = 'uploads';
export const BUCKET_BILDER = 'generated-images';
/**
* Roh-URL einer Datei in Appwrite.
*
* Die Buckets haben `fileSecurity`, die Datei trägt nur die Team-Permission
* der Abruf braucht also die Session. Ob die mitgeht, hängt von der Plattform
* ab; deshalb geht die Anzeige nicht über diese URL, sondern über
* `useBildQuelle` (siehe `bildquelle.web.ts`).
*/
export function dateiUrl(fileId: string, bucketId: string = BUCKET_REFERENZEN): string {
const { endpoint, project } = appwriteConfig;
return `${endpoint}/storage/buckets/${bucketId}/files/${fileId}/view?project=${project}`;
}

167
client/src/lib/folders.ts Normal file
View File

@@ -0,0 +1,167 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { teamRechte } from './permissions';
/**
* Ein Ordner ist ein **privater Wissens-Scope**, kein Sortier-Ordner
* (konzept-bilder-feed.md §9). Beim Generieren zieht P4 nur die Attribute des
* gewählten Ordners er überschreibt die brand-weite Ebene vollständig,
* gemischt wird nicht.
*/
export type Ordner = {
$id: string;
brand_id: string;
name: string;
theme_md?: string;
zweck?: string;
startwert_modus?: Startwert;
ist_default?: boolean;
post_count?: number;
signal_count?: number;
};
/** `sammlung` = nur einsortieren · `wissens_scope` = daraus erstellen. */
export type Zweck = 'sammlung' | 'wissens_scope';
/** Woher die Attribut-Scores beim Anlegen kommen. */
export type Startwert = 'erben' | 'aus_posts' | 'neutral';
export const ZWECKE: { wert: Zweck; titel: string; erklaerung: string }[] = [
{
wert: 'wissens_scope',
titel: 'Daraus erstellen',
erklaerung: 'Generierungen ziehen ihr Wissen aus diesem Ordner. Das ist der Normalfall.',
},
{
wert: 'sammlung',
titel: 'Nur sammeln',
erklaerung: 'Reine Ablage zum Sortieren. Beeinflusst keine Generierung.',
},
];
export const STARTWERTE: { wert: Startwert; titel: string; erklaerung: string }[] = [
{
wert: 'erben',
titel: 'Vom Konto erben',
erklaerung: 'Startet mit einer Kopie der bisherigen Scores. Gut, wenn der Ordner die Marke fortsetzt.',
},
{
wert: 'aus_posts',
titel: 'Nur aus den Posts',
erklaerung:
'Startet leer und lernt ausschließlich aus dem, was hier landet. Gut, wenn der Ordner bewusst anders aussehen soll als der Rest.',
},
];
export async function ordnerListe(brandId: string): Promise<Ordner[]> {
const res = await tables.listRows({
databaseId,
tableId: 'folders',
queries: [Query.equal('brand_id', brandId), Query.limit(100)],
});
return res.rows as unknown as Ordner[];
}
export async function ordnerLesen(id: string): Promise<Ordner> {
const row = await tables.getRow({ databaseId, tableId: 'folders', rowId: id });
return row as unknown as Ordner;
}
export async function ordnerAnlegen(
brandId: string,
teamId: string,
daten: { name: string; zweck: Zweck; startwert_modus: Startwert; theme_md?: string },
): Promise<Ordner> {
const row = await tables.createRow({
databaseId,
tableId: 'folders',
rowId: ID.unique(),
data: {
brand_id: brandId,
name: daten.name,
zweck: daten.zweck,
startwert_modus: daten.startwert_modus,
theme_md: daten.theme_md ?? '',
ist_default: false,
post_count: 0,
signal_count: 0,
},
permissions: teamRechte(teamId),
});
const ordner = row as unknown as Ordner;
await ordnerInitialisieren(brandId, teamId, ordner.$id, daten.startwert_modus);
return ordner;
}
/**
* Füllt `attribute_scores` für einen frischen Ordner.
*
* `erben` kopiert die brand-weite Ebene (`folder_id = null`) der Ordner setzt
* die Marke fort. `aus_posts` und `neutral` starten leer: dort soll das Wissen
* ausschließlich aus dem entstehen, was später hineinkommt. Genau das ist der
* Zweck der Ordner Wissen **segmentieren statt mitteln**
* (konzept-bilder-feed.md §9).
*
* Läuft vorerst im Client statt in der Function `ordner-initialisieren`. Das
* ist vertretbar, weil nur eigene Zeilen kopiert werden und die Zeilenrechte
* das ohnehin begrenzen beim Umzug in eine Function ändert sich nur der Ort.
*/
export async function ordnerInitialisieren(
brandId: string,
teamId: string,
folderId: string,
modus: Startwert,
): Promise<number> {
if (modus !== 'erben') return 0;
const quelle = await tables.listRows({
databaseId,
tableId: 'attribute_scores',
queries: [Query.equal('brand_id', brandId), Query.isNull('folder_id'), Query.limit(200)],
});
const rechte = teamRechte(teamId);
let kopiert = 0;
for (const z of quelle.rows as unknown as BrandScore[]) {
await tables.createRow({
databaseId,
tableId: 'attribute_scores',
rowId: ID.unique(),
data: {
brand_id: brandId,
attribute_id: z.attribute_id,
category_id: z.category_id,
folder_id: folderId,
score: z.score ?? 5000,
start_value: z.score ?? 5000,
// Der Ordner erbt den Wert, aber nicht die Sicherheit: k_factor zurück
// auf 32, weil im neuen Scope noch nichts belegt ist.
k_factor: 32,
start_quelle: 'geerbt',
used_count: 0, wins: 0, losses: 0,
},
permissions: rechte,
});
kopiert++;
}
return kopiert;
}
type BrandScore = {
attribute_id: string;
category_id: string;
score?: number;
};
export async function ordnerLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'folders', rowId: id });
}
/** „4 Posts lernt noch“: ohne Reifegrad ist für den Nutzer nicht erklärbar,
* warum zwei gleiche Prompts verschiedene Bilder ergeben (app-aufbau.md §5.3). */
export function reifegrad(o: Ordner): string {
const posts = o.post_count ?? 0;
const signale = o.signal_count ?? 0;
if (posts === 0) return 'leer';
if (signale < 5) return `${posts} Posts lernt noch`;
return `${posts} Posts eingespielt`;
}

View File

@@ -0,0 +1,13 @@
import { Permission, Role } from './appwrite';
/**
* Die Rechte, die jede Zeile und jede Datei einer Brand bekommt.
*
* Kein `create` das regelt das Tabellen-Recht, und die Zeile existiert beim
* Setzen ja bereits. Kein `read("any")`: öffentlich wird ausschließlich ein
* veröffentlichter Post, und zwar gezielt beim Veröffentlichen (E7).
*/
export function teamRechte(teamId: string): string[] {
const r = Role.team(teamId);
return [Permission.read(r), Permission.update(r), Permission.delete(r)];
}

166
client/src/lib/posts.ts Normal file
View File

@@ -0,0 +1,166 @@
import { ID, Query, databaseId, tables } from './appwrite';
import { teamRechte } from './permissions';
/**
* Ein Post ist ein **Slot-Rezept**, kein Freitext-Prompt nur deshalb ist er
* überhaupt kopierbar (konzept-bilder-feed.md §3). Die Slots liegen strukturiert
* in `slots`, damit P19 daraus später Chips ableiten kann, ohne den Prompt im
* Wortlaut preiszugeben.
*/
export type Slots = {
kulisse_asset_id?: string;
person_asset_id?: string;
produkt_asset_id?: string;
licht?: string;
kamera?: string;
farbe?: string;
werbetext?: string;
};
export type Post = {
$id: string;
brand_id: string;
folder_id?: string;
titel?: string;
user_prompt?: string;
slots?: string;
format?: string;
bild_count?: number;
status?: 'entwurf' | 'generiert' | 'fehler';
sichtbarkeit?: 'privat' | 'oeffentlich';
nische?: string;
};
export const FORMATE = [
{ wert: '1:1', titel: 'Quadrat', erklaerung: 'Feed-Beiträge, Produktkacheln.' },
{ wert: '4:5', titel: 'Hochformat', erklaerung: 'Nimmt im Feed mehr Fläche ein.' },
{ wert: '9:16', titel: 'Story', erklaerung: 'Bildschirmfüllend, für Stories und Reels.' },
] as const;
export type Format = (typeof FORMATE)[number]['wert'];
export async function postsImOrdner(folderId: string): Promise<Post[]> {
const res = await tables.listRows({
databaseId,
tableId: 'posts',
queries: [Query.equal('folder_id', folderId), Query.orderDesc('$createdAt'), Query.limit(100)],
});
return res.rows as unknown as Post[];
}
export async function postsDerMarke(brandId: string): Promise<Post[]> {
const res = await tables.listRows({
databaseId,
tableId: 'posts',
queries: [Query.equal('brand_id', brandId), Query.orderDesc('$createdAt'), Query.limit(100)],
});
return res.rows as unknown as Post[];
}
/**
* Legt das Rezept an und reiht die Generierungen ein.
*
* Der Post entsteht **sofort** mit `status: 'entwurf'`, bevor irgendetwas
* generiert wird. Das ist Absicht: Generierung ist asynchron, und wer während
* des Wartens weg navigiert, muss das Ergebnis wiederfinden (app-aufbau.md
* §2.1). Ohne die Zeile gäbe es nichts, wohin man zurückkehren könnte.
*
* Je Bild der Kette eine `jobs`-Zeile. Abgearbeitet werden sie vom
* Job-Dispatcher mit Server-Key **nicht** vom Client, denn dafür müsste der
* Anbieter-Schlüssel ins Bundle.
*/
export async function postAnlegen(
brandId: string,
teamId: string,
daten: {
folderId: string;
titel: string;
prompt: string;
format: Format;
bildCount: number;
slots: Slots;
nische?: string;
},
): Promise<Post> {
const rechte = teamRechte(teamId);
const post = (await tables.createRow({
databaseId,
tableId: 'posts',
rowId: ID.unique(),
data: {
brand_id: brandId,
folder_id: daten.folderId,
titel: daten.titel,
user_prompt: daten.prompt,
slots: JSON.stringify(daten.slots),
format: daten.format,
bild_count: daten.bildCount,
status: 'entwurf',
sichtbarkeit: 'privat', // Veröffentlichen ist ein aktiver, eigener Schritt
kopien_count: 0,
feed_score: 0,
...(daten.nische ? { nische: daten.nische } : {}),
},
permissions: rechte,
})) as unknown as Post;
for (let i = 0; i < daten.bildCount; i++) {
await tables.createRow({
databaseId,
tableId: 'jobs',
rowId: ID.unique(),
data: {
brand_id: brandId,
typ: 'bild_gen',
prompt_template_key: 'P7',
status: 'wartend',
refs: JSON.stringify({ post_id: post.$id, position: i + 1 }),
},
permissions: rechte,
});
}
return post;
}
export type PostBild = {
$id: string;
post_id: string;
position: number;
typ: 'motiv' | 'text_overlay';
storage_file_id?: string;
};
/**
* Die Bilderketten mehrerer Posts in einer Abfrage nicht eine je Post.
* Ein Ordner mit 20 Posts wären sonst 20 Rundreisen für eine Listenansicht.
*/
export async function bilderZuPosts(postIds: string[]): Promise<Map<string, PostBild[]>> {
const nach = new Map<string, PostBild[]>();
if (!postIds.length) return nach;
const res = await tables.listRows({
databaseId,
tableId: 'post_images',
queries: [Query.equal('post_id', postIds), Query.orderAsc('position'), Query.limit(200)],
});
for (const b of res.rows as unknown as PostBild[]) {
const liste = nach.get(b.post_id) ?? [];
liste.push(b);
nach.set(b.post_id, liste);
}
return nach;
}
export async function postLoeschen(id: string): Promise<void> {
await tables.deleteRow({ databaseId, tableId: 'posts', rowId: id });
}
export function slotsLesen(post: Post): Slots {
try {
return post.slots ? (JSON.parse(post.slots) as Slots) : {};
} catch {
return {};
}
}

101
client/src/lib/session.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import {
abmelden as abmeldenApi,
aktuellesKonto,
brandSicherstellen,
eigeneBrand,
type Brand,
type Konto,
} from './auth';
import { ordnerListe, type Ordner } from './folders';
type Session = {
/** true, solange noch nicht feststeht, ob jemand angemeldet ist. */
laedt: boolean;
konto: Konto | null;
brand: Brand | null;
/**
* Der aktive Ordner bestimmt, welches Wissen in den Prompt wandert. Er ist
* deshalb App-Zustand und kein Bildschirm-lokaler Wert (app-aufbau.md §2.2)
* sonst generiert der Nutzer im falschen Scope und versteht das Ergebnis nicht.
*/
aktiverOrdner: Ordner | null;
ordner: Ordner[];
setzeAktivenOrdner: (o: Ordner | null) => void;
ordnerNeuLaden: () => Promise<void>;
neuLaden: () => Promise<void>;
ausloggen: () => Promise<void>;
};
const Ctx = createContext<Session | null>(null);
export function SessionProvider({ children }: { children: ReactNode }) {
const [laedt, setLaedt] = useState(true);
const [konto, setKonto] = useState<Konto | null>(null);
const [brand, setBrand] = useState<Brand | null>(null);
const [ordner, setOrdner] = useState<Ordner[]>([]);
const [aktiverOrdner, setzeAktivenOrdner] = useState<Ordner | null>(null);
const ordnerFuer = useCallback(async (b: Brand | null) => {
if (!b) {
setOrdner([]);
setzeAktivenOrdner(null);
return;
}
const liste = await ordnerListe(b.$id);
setOrdner(liste);
setzeAktivenOrdner((bisher) => {
if (bisher) return liste.find((o) => o.$id === bisher.$id) ?? liste[0] ?? null;
return liste.find((o) => o.ist_default) ?? liste[0] ?? null;
});
}, []);
const neuLaden = useCallback(async () => {
const k = await aktuellesKonto();
setKonto(k);
if (!k) {
setBrand(null);
await ordnerFuer(null);
setLaedt(false);
return;
}
// Konto ohne Brand kann entstehen, wenn die Registrierung mittendrin
// abgebrochen ist hier wird das stillschweigend nachgeholt.
let b = await eigeneBrand();
if (!b) b = await brandSicherstellen(k.name || k.email);
setBrand(b);
await ordnerFuer(b);
setLaedt(false);
}, [ordnerFuer]);
const ordnerNeuLaden = useCallback(() => ordnerFuer(brand), [brand, ordnerFuer]);
const ausloggen = useCallback(async () => {
await abmeldenApi();
setKonto(null);
setBrand(null);
setOrdner([]);
setzeAktivenOrdner(null);
}, []);
useEffect(() => {
void neuLaden();
}, [neuLaden]);
return (
<Ctx.Provider
value={{
laedt, konto, brand, ordner, aktiverOrdner,
setzeAktivenOrdner, ordnerNeuLaden, neuLaden, ausloggen,
}}>
{children}
</Ctx.Provider>
);
}
export function useSession(): Session {
const v = useContext(Ctx);
if (!v) throw new Error('useSession muss innerhalb von <SessionProvider> stehen.');
return v;
}

29
client/src/lib/upload.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Datei-Upload **native**. Gegenstück: `upload.web.ts`.
*
* Das native SDK will die Datei als `{name, type, size, uri}` und liest sie
* selbst vom Dateisystem; das Web-SDK will ein `File`-Objekt. Deshalb liegt der
* Upload genauso plattform-getrennt wie der Client selbst.
*/
import { ID, storage } from './appwrite';
export type Auswahl = {
uri: string;
name: string;
mimeType: string;
size: number;
};
export async function hochladen(
bucketId: string,
datei: Auswahl,
permissions: string[],
): Promise<string> {
const f = await storage.createFile({
bucketId,
fileId: ID.unique(),
file: { name: datei.name, type: datei.mimeType, size: datei.size, uri: datei.uri },
permissions,
});
return f.$id;
}

View File

@@ -0,0 +1,34 @@
/**
* Datei-Upload **Web**. Gegenstück: `upload.ts`, siehe dortiger Kopf.
*
* Der Picker liefert im Browser eine blob:- oder data:-URI. Das Web-SDK will
* ein echtes `File`, also wird die URI einmal gelesen und umgepackt.
*/
// Bewusst `./appwrite.web` und nicht `./appwrite`: TypeScript löst die
// Plattform-Endung nicht auf und würde sonst die native Signatur prüfen
// (`{name,type,size,uri}` statt `File`). Metro lädt auf Web ohnehin dieselbe
// Datei, der explizite Pfad ändert am Ergebnis nichts nur an der Prüfung.
import { ID, storage } from './appwrite.web';
export type Auswahl = {
uri: string;
name: string;
mimeType: string;
size: number;
};
export async function hochladen(
bucketId: string,
datei: Auswahl,
permissions: string[],
): Promise<string> {
const blob = await (await fetch(datei.uri)).blob();
const file = new File([blob], datei.name, { type: datei.mimeType || blob.type });
const f = await storage.createFile({
bucketId,
fileId: ID.unique(),
file,
permissions,
});
return f.$id;
}

View File

@@ -0,0 +1,54 @@
/**
* Design-Tokens, übernommen aus `prototyp-app.html` (CSS-Custom-Properties).
*
* Der Prototyp ist ein reines Dark Design es gibt dort kein Light-Theme und
* deshalb hier auch keins. `userInterfaceStyle` steht in app.json bewusst auf
* "dark", damit die App auf hellen Systemen nicht halb umkippt.
*
* Nicht übernommen (siehe programmier-plan.md E1): die Fake-Tastatur und die
* Shader-Spielerei per setInterval. Beides ist Design-Requisit, kein App-Code.
*/
export const colors = {
bg: '#0a0a0a',
card: '#161616',
sheet: '#131316',
txt: '#ffffff',
mut: 'rgba(255,255,255,0.6)',
faint: 'rgba(255,255,255,0.4)',
border: 'rgba(255,255,255,0.09)',
borderStrong: 'rgba(255,255,255,0.18)',
surface: 'rgba(255,255,255,0.05)',
surface2: 'rgba(255,255,255,0.10)',
accent: '#ff5c39',
gold: '#fbbf24',
ok: '#34d399',
} as const;
/** Der Prototyp nutzt 1226px; hier auf eine Leiter reduziert. 100 = Pille. */
export const radius = {
sm: 12,
md: 16,
lg: 20,
xl: 24,
pill: 100,
} as const;
export const space = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const;
export const text = {
title: { fontSize: 28, fontWeight: '700' },
heading: { fontSize: 20, fontWeight: '600' },
body: { fontSize: 15, fontWeight: '400' },
label: { fontSize: 13, fontWeight: '500' },
mono: { fontSize: 12, fontFamily: 'monospace' },
} as const;
export type Colors = typeof colors;

20
client/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./src/*"
],
"@/assets/*": [
"./assets/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}

View File

@@ -7,11 +7,23 @@ Setze das Design aus diesem Ordner in unserem Stack um.
Zum Ansehen im Browser öffnen — `support.js` muss im selben Ordner liegen.
Diese Dateien sind Design-Referenz, kein Produktionscode: nicht kopieren, sondern
in unserer bestehenden Umgebung mit unseren Patterns und Komponenten nachbauen.
3. Fidelity ist high — pixelgenau umsetzen: Farben, Radien, Übergänge, Hover-/Focus-States,
Drag-Schwellen und Animationsdauern wie im README angegeben.
4. Umfang: App-Shell (Sidebar + Kopfzeile mit Tabs) und die vier Screens
Feed, Profil, Erstellen, Einstellungen.
5. Icons als Lucide-Icons umsetzen (im Prototyp stehen Unicode-Glyphen als Platzhalter).
3. Fidelity ist high — pixelgenau umsetzen: Farben, Radien, Übergänge, Hover-/Focus-States
und Animationsdauern wie im README angegeben.
Die visuelle Sprache ist Pinterest Desktop: weißer Hintergrund, 80px-Icon-Rail ohne
Beschriftung, Suchfeld als Pille, rote Primäraktion `#e60023`, randlose 16px-Kacheln,
Masonry-Raster.
4. Umfang: App-Shell (Icon-Rail + Kopfzeile + Tab-Leiste) und die vier Screens
Feed, Profil, Erstellen, Einstellungen — inklusive Detailansicht als Overlay
und Auswahlmodus mit Sammelaktion.
5. **Kein Swipen.** Der Feed ist ein Raster, kein Kartenstapel: keine Drag-Geste, keine
Pfeiltasten, kein Undo. „Gemerkt" ist ein Zustand, den ein zweiter Klick zurücknimmt.
6. Karten werden über ihre `id` referenziert (Merken, Ausblenden, Auswahl, Detailansicht),
nicht über einen Index.
7. Hover-Elemente (Merken-Pille, ⋯, Scrim) hängen am Hover der Kachel — `:focus-within`
mitnehmen, sonst sind sie per Tastatur nicht erreichbar.
8. Icons als Lucide-Icons umsetzen (im Prototyp stehen Unicode-Glyphen als Platzhalter).
Medien-Platzhalter (CSS-Verläufe mit Streifen) durch echte Bilder/Videos ersetzen.
Frag nach, bevor du Struktur oder Verhalten abweichend vom README änderst.
Die offenen Punkte am Ende des README (Suche, dauerhaftes Ausblenden, Nachladen)
vor der Umsetzung klären.

View File

@@ -1,16 +1,26 @@
# Handoff: Videogen — Web-App (Desktop-Adaption der Mobile-App)
## Überblick
Die bestehende Mobile-App („videogen", Swipe-Feed für Creator-/Produkt-Posts) als Desktop-Web-App.
Statt einer schmalen Handy-Spalte im Browser: dreispaltiges App-Layout mit permanenter Navigation,
zentralem Kartenstapel und Detailspalte. Swipen wird durch Maus-Drag, Buttons und Tastatur ersetzt.
Die bestehende Mobile-App („videogen") als Desktop-Web-App. Statt einer schmalen Handy-Spalte im
Browser: Icon-Rail links, breite Kopfzeile mit Suche und ein **Masonry-Raster** als Feed.
**Das Swipen entfällt.** In der Mobile-App wird eine Karte nach der anderen durchgeschoben; auf dem
Desktop liegen stattdessen alle Karten nebeneinander und werden einzeln gemerkt, ausgeblendet oder
geöffnet. Damit fallen Kartenstapel, Maus-Drag, Pfeiltasten, die Exit-Animation und das Undo weg —
„gemerkt" ist jetzt ein Zustand, den ein zweiter Klick zurücknimmt, keine Entscheidung in einer
History.
**Visuelle Sprache: Pinterest Desktop.** Heller Hintergrund, schmale Icon-Leiste ohne Beschriftung,
Suchfeld als breite Pille, rote Primäraktion („Merken"), randlose Karten mit 16px-Radius,
gestaffeltes Raster. Referenz ist die Pinterest-Web-App (`de.pinterest.com`); der Screenshot dafür
liegt bisher nicht im Repo, siehe [Offene Punkte](#offene-punkte).
## Zu den Design-Dateien
Die Dateien in diesem Bundle sind **Design-Referenzen in HTML** — Prototypen, die Aussehen und
Verhalten zeigen, **kein Produktionscode zum Kopieren**. Aufgabe ist, diese Designs in der bestehenden
Umgebung des Ziel-Codebases nachzubauen (React, Vue, Svelte, Next.js …) mit dessen etablierten Patterns
und Bibliotheken. Existiert noch keine Umgebung, das passendste Framework wählen (Empfehlung: React +
TypeScript, Motion/Framer Motion für die Kartenanimation) und dort umsetzen.
TypeScript) und dort umsetzen.
Die HTML-Datei ist ein „Design Component"-Format: `Videogen Web.dc.html` enthält Template + Logikklasse,
`support.js` ist die Laufzeit dazu. Zum Ansehen: beide Dateien im selben Ordner belassen und die
@@ -27,177 +37,188 @@ und sollen pixelgenau nachgebaut werden — mit den Komponenten/Utilities des Zi
- Wurzel: `display:flex`, `min-height:100vh`, **`min-width:1180px`** (darunter horizontales Scrollen,
die App ist Desktop-only gedacht).
- Hintergrund (fix, nicht mitscrollend):
`radial-gradient(120% 80% at 50% 0%, #0d4c3d 0%, #0a3a30 34%, #24121a 68%, #40161b 100%)`,
`background-attachment: fixed`. Oben Tannengrün, unten Weinrot — direkt aus der App übernommen.
- Grundschrift `#f2efe9` auf diesem Verlauf.
- Hintergrund durchgehend `#ffffff`, Grundschrift `#111111`. Keine Verläufe, kein `backdrop-filter`.
### 1. Sidebar (links)
- Breite `236px`, `flex:none`, `position:sticky; top:0; height:100vh`.
- `background: rgba(8,26,22,.42)`, `backdrop-filter: blur(14px)`, rechte Kante `1px solid rgba(255,255,255,.08)`.
- Padding `26px 20px`, Inhalt als Flex-Spalte, `gap:28px`.
- **Brand**: 30×30 Kachel, `border-radius:9px`, `linear-gradient(150deg,#2fa87f,#0e5442)`, Buchstabe „V" 15px/700;
daneben Wortmarke „videogen" 16px/600, `letter-spacing:-.01em`.
- **Nav** (Flex-Spalte, `gap:4px`), Einträge: Feed, Profil, Erstellen, Einstellungen.
- Item: `display:flex; gap:11px; padding:11px 13px; border-radius:11px; font:500 14px Manrope; text-align:left; cursor:pointer`.
- Inaktiv: transparent, Text `rgba(242,239,233,.7)`, Border transparent.
- Aktiv: `background: rgba(126,226,184,.12)`, `border:1px solid rgba(126,226,184,.4)`, Text `#d6fbec`.
- Hover: `background: rgba(255,255,255,.09)`, Text `#ffffff`. Transition `.18s ease`.
- Icon-Slot 20px breit (im Prototyp Glyphen ◧ ◍ ⚙ — **in der Implementierung durch Lucide-Icons ersetzen**:
`layout-grid`, `user`, `plus`, `settings`).
- **Fußbereich** (`margin-top:auto`):
- Statistik-Karte: `1px solid rgba(255,255,255,.12)`, `radius:14px`, `padding:14px`, `background: rgba(255,255,255,.03)`.
Kicker „HEUTE" 11px, `letter-spacing:.14em`, uppercase, `rgba(242,239,233,.5)`; Zahl 22px JetBrains Mono,
`font-variant-numeric: tabular-nums`, zweistellig mit führender Null; Label „Karten gesichtet" 12px `rgba(242,239,233,.55)`.
- Konto-Zeile: 32px Kreis `linear-gradient(140deg,#b2323c,#5d1620)`, Name 13px/600, Rolle 11px `rgba(242,239,233,.5)`.
### 1. Icon-Rail (links)
- Breite `80px`, `flex:none`, `position:sticky; top:0; height:100vh`, `background:#ffffff`,
rechte Kante `1px solid #efefef`. Padding `12px 0 16px`, zentrierte Flex-Spalte, `gap:8px`.
- **Brand**: 32×32 Kreis `#e60023` in einem 48px-Slot, weißes „V" 17px/700.
- **Nav-Buttons**: 48×48, `border-radius:50%`, Icon 20px, **ohne Textlabel** — der Name steht im
`title`-Attribut und wird als Tooltip gezeigt.
- Inaktiv: transparent, Icon `#111111`. Hover: `background:#e9e9e9`.
- Aktiv: `background:#111111`, Icon `#ffffff`. Aktiv + Hover: `background:#000000`.
- Transition `.12s ease`.
- **Umsetzungshinweis:** Die Zustände gehören in Klassen (`.rail`, `.rail.active`), nicht in
Inline-Styles. Im Prototyp setzt die `.dc`-Laufzeit `style-hover` mit `!important`; ein inline
gesetztes Aktiv-Schwarz würde beim Hover überschrieben und der aktive Button hellte auf.
- Reihenfolge oben: **Feed, Profil, Erstellen**. Unten (`margin-top:auto`): **Einstellungen**.
- Icons (im Prototyp Unicode-Glyphen als Platzhalter) — **Lucide**: `home`, `user`, `plus`, `settings`.
### 2. Kopfzeile (über dem Inhalt)
- Höhe `72px`, `padding: 0 34px`, Unterkante `1px solid rgba(255,255,255,.07)`, Inhalt `space-between`.
- **Tabs** links (`gap:6px`): „Videos", „Folge ich", „Posts". Jeder Tab wechselt den Kartenstapel und
springt zurück auf den Feed-Screen (Index/History zurücksetzen).
- `padding:9px 18px; border-radius:999px; font:500 16px Manrope`.
- Aktiv: `background: rgba(255,255,255,.08)`, `border:1px solid rgba(255,255,255,.22)`, Text `#fff`, 600.
- Inaktiv: transparent, Text `rgba(242,239,233,.58)`; Hover `#fff`.
- **Rechts**: Zähler „3 / 5" (12px JetBrains Mono, tabular-nums, `rgba(242,239,233,.5)`) und runder
Info-Button 36px (`border:1px solid rgba(255,255,255,.18)`, `background: rgba(255,255,255,.04)`;
Hover `rgba(255,255,255,.12)` + `translateY(-1px)`), toggelt das Kürzel-Panel.
### 2. Kopfzeile
- `padding:12px 16px 4px`, Flex-Zeile, `gap:8px`.
- **Suchfeld**: `flex:1`, Höhe `48px`, `border-radius:24px`, `background:#efefef`, `padding:0 16px`,
Platzhaltertext „Suchen" 16px `#767676`; links Lupe, rechts Objektiv-Symbol
(Lucide `search` und `camera`).
- **Konto**: 40px Kreis `linear-gradient(140deg,#b2323c,#5d1620)` mit weißer Initiale 15px/700,
daneben 28px Chevron-Button (`▾`, Hover `#e9e9e9`).
### 3. Tab-Leiste
- `padding:0 24px`, Unterkante `1px solid #efefef`, Tabs mit `gap:24px`, an der Grundlinie ausgerichtet.
- **Tabs**: „Videos", „Folge ich", „Posts" — drei Raster. Ein Wechsel springt auf den Feed-Screen
und verwirft eine laufende Auswahl.
- `padding:0 2px 12px`, Unterkante `3px solid` — aktiv `#111111`, inaktiv `transparent`.
- Aktiv: `#111111`, 700. Inaktiv: `#767676`, 500; Hover → `#111111`. Schriftgröße 16px.
- **Rechts**: Zähler „**9 Karten · 4 gemerkt**" (13px `#767676`) und der
**Auswählen-Umschalter**: Pille `padding:10px 18px`, `radius:22px`, `font:700 15px`;
aus → `#efefef`/`#111111` mit Beschriftung „Auswählen", an → `#111111`/`#ffffff` mit „Fertig".
---
## Screens
### A. Feed (Startansicht)
**Zweck:** Karten sichten, merken oder überspringen.
**Zweck:** Material sichten, Gutes merken, Uninteressantes ausblenden.
**Layout:** `display:grid; grid-template-columns: minmax(0,1fr) 372px; flex:1; min-height:0; overflow:auto`.
- Bereich: `flex:1; min-height:0; overflow:auto; padding:24px 24px 96px`
(unten Platz für die Auswahlleiste).
- **Masonry-Raster**: `columns:5; column-gap:16px` — Spaltenzahl ist als Prop einstellbar (36).
Kachel-Wrapper: `break-inside:avoid; margin-bottom:16px; position:relative`.
#### A1 Kartenbühne (linke Spalte)
- Zentriert, `padding:26px 20px 30px`, `gap:22px`, `position:relative`.
- Bühne: `position:relative; width:min(404px,100%); height:560px; flex:none`.
- Es werden **bis zu 3 Karten** gestapelt gerendert (konfigurierbar 15).
- Karte k (0 = oben): `transform: translate3d(0, -12px·k, 0) scale(1 - 0.045·k)`, `z-index: 10-k`,
`pointer-events` nur auf der obersten.
- Karte: `position:absolute; inset:0; border-radius:26px; overflow:hidden;
border:1px solid rgba(255,255,255,.14); box-shadow: 0 26px 60px rgba(0,0,0,.45);
touch-action:none; user-select:none; cursor:grab` (beim Ziehen `grabbing`).
- Transition: `transform .34s cubic-bezier(.22,.9,.3,1), opacity .3s ease` — während des aktiven Drags `none`.
- **Karteninhalt (von unten nach oben gestapelt):**
1. Grundfläche: `linear-gradient(155deg, <hue0> 0%, <hue1> 100%)` (pro Karte, siehe Daten unten).
2. Streifen-Overlay: `repeating-linear-gradient(<40+9·i>deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)`, `opacity:.5`.
(Im Prototyp Platzhalter für das Video-/Bild-Asset — im Produkt liegt hier das Medium.)
3. Lesbarkeits-Verlauf: `linear-gradient(180deg, rgba(0,0,0,.32) 0%, transparent 26%, transparent 52%, rgba(0,0,0,.66) 100%)`.
4. **Pills** oben links (`padding:18px`, `gap:9px`): Art („Produkt"/„Video") und Assets („3 Bilder").
`padding:7px 15px; border-radius:999px; border:1px solid rgba(255,255,255,.34);
background: rgba(0,0,0,.22); backdrop-filter: blur(6px); font:600 13px`.
5. **Caption** mittig, 118px über der Unterkante: JetBrains Mono 15px, `padding:9px 16px`,
`border-radius:9px`, `background: rgba(0,0,0,.5)`, `backdrop-filter: blur(4px)`.
6. **Creator-Zeile** unten (`padding:20px`, `gap:13px`): 46px Avatar-Kreis (Farbverlauf, `border:2px solid rgba(255,255,255,.3)`),
Name 19px/700 `letter-spacing:-.01em`, Untertitel 14px `rgba(255,255,255,.72)`.
7. **Entscheidungs-Badge** (nur oberste Karte, erscheint ab 40px Drag oder beim Exit):
`top:86px`, links bei „GEMERKT" / rechts bei „SKIP", `padding:8px 16px; border-radius:10px;
font:800 15px; letter-spacing:.12em`, `border:2px solid` und Text in
Grün `rgba(126,226,184,.9)` / `#a9f0d0` bzw. Rot `rgba(232,120,120,.9)` / `#f0a8a8`,
`rotate(-10deg)` bzw. `rotate(10deg)`, Opacity `min(1, |dx|/110)`, Transition `.16s`.
- **Leerzustand** (Stapel durch): gestrichelter Rahmen `1px dashed rgba(255,255,255,.2)`, `radius:26px`,
Titel „Alles gesichtet" 20px/600, Text „Für heute sind keine neuen Karten mehr in dieser Ansicht.",
Outline-Button „Stapel neu laden" (`border:1px solid rgba(126,226,184,.6)`, Text `#8fe3c1`,
Hover `background: rgba(126,226,184,.14)`), Einblendung `fadeIn .3s`.
- **Aktions-Buttons** unter der Bühne (`gap:18px`), alle `border-radius:50%`,
`background: rgba(14,10,12,.72)`, `border:1px solid rgba(255,255,255,.16)`,
Transition `.18s`, Hover `translateY(-3px) scale(1.05)`, Active `scale(.95)`:
- ✕ Ablehnen — 62px, Hover-Border `rgba(230,120,120,.75)`, Hover-BG `rgba(120,32,38,.5)`.
- ↺ Zurück — 52px, Hover-Border `rgba(255,255,255,.4)`.
- ♥ Merken — 70px, Text `#8fe3c1`, Border `rgba(126,226,184,.45)`, Hover-BG `rgba(20,88,68,.6)`, Hover `scale(1.06)`.
(Icons in der Implementierung: Lucide `x`, `rotate-ccw`, `heart`.)
- **Kürzel-Panel** (Info-Button): absolut `right:26px; top:26px`, 250px, `radius:14px`,
`background: rgba(10,26,22,.9)`, `blur(12px)`, `border:1px solid rgba(255,255,255,.14)`,
Einblendung `riseIn .22s`. Inhalt: ← Ablehnen, → Merken, ↑ Zurücknehmen, Maus Karte ziehen.
#### A1 Kachel
- Medienfläche: `position:relative`, **variable Höhe** (Zyklus 320 / 400 / 260 / 340 / 280 / 440 /
300 / 360 / 240 / 380 px), `border-radius:16px`, `overflow:hidden`, **kein Rahmen**,
`cursor:zoom-in` (im Auswahlmodus `pointer`).
- Inhalt von unten nach oben:
1. Grundfläche `linear-gradient(155deg, <hue0> 0%, <hue1> 100%)`.
2. Streifen-Overlay `repeating-linear-gradient(<40+9·i>deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)`,
`opacity:.45`. (Platzhalter für das Medium — im Produkt liegt hier Bild oder Video.)
3. **Hover-Scrim** `rgba(0,0,0,.24)`, `opacity` 0 → 1 in `.12s`.
4. Fußzeile auf `linear-gradient(0deg, rgba(0,0,0,.62), transparent)`, `padding:14px`:
Caption 14px/700 weiß, darunter „Name · Art" 12px `rgba(255,255,255,.82)`.
- **Hover-Steuerung** (beide `opacity` 0 → 1 in `.12s`, gebunden an den Hover der *Kachel*):
- **Merken-Pille** oben rechts (`right:12px; top:12px`): `padding:11px 16px; radius:22px;
font:700 15px`, ungemerkt `#e60023` mit weißem Text, **gemerkt `#111111` mit „Gemerkt"**.
Klick schaltet um — ein zweiter Klick nimmt zurück.
- **⋯-Button** unten rechts (`right:12px; bottom:12px`): 40px Kreis `#ffffff`,
`box-shadow: 0 1px 8px rgba(0,0,0,.2)`, Hover `#e9e9e9`.
- **⋯-Menü**: öffnet über dem Button (`right:12px; bottom:56px`), 210px, `radius:16px`,
`background:#ffffff`, `box-shadow: 0 1px 20px rgba(0,0,0,.22)`, `padding:8px`,
Einblendung `riseIn .14s`. Einträge `padding:12px 14px`, `radius:12px`, 15px/600,
Hover `#efefef`: **„Nicht mehr zeigen"** (blendet die Kachel aus) und **„Details öffnen"**.
Ein zweiter Klick auf ⋯ schließt das Menü, `Escape` ebenfalls.
- **Leerzustand** (alles ausgeblendet): zentriert, Titel „Nichts mehr in dieser Ansicht" 20px/700,
Text 15px `#5f5f5f`, roter Pill-Button „Ausgeblendete wieder anzeigen".
#### A2 Detailspalte (rechts, 372px)
- `border-left:1px solid rgba(255,255,255,.07)`, `background: rgba(8,22,19,.34)`, `padding:30px 28px`,
Flex-Spalte `gap:24px`, `overflow:auto`.
- Kicker „AKTUELLE KARTE" (11px, `letter-spacing:.16em`, uppercase, `rgba(242,239,233,.45)`),
Titel = Caption der obersten Karte 26px/600 `letter-spacing:-.02em`, darunter „Name · Untertitel" 14px `rgba(242,239,233,.62)`.
- Hairline `1px rgba(255,255,255,.09)`.
- **Statistik-Raster** 3 Spalten, `gap:12px`: Kachel `border:1px solid rgba(255,255,255,.1)`, `radius:12px`,
`padding:12px`; Wert 17px JetBrains Mono tabular-nums, Label 11px `rgba(242,239,233,.5)`.
- **Beschreibung**: Kicker + Fließtext 14px, `line-height:1.75`, `rgba(242,239,233,.78)`, `text-wrap:pretty`.
- **Tags**: Pills `padding:6px 12px`, `radius:999px`, `border:1px solid rgba(255,255,255,.18)`, 12px.
- **Aktionen unten** (`margin-top:auto`, `gap:10px`), beide `padding:13px; radius:12px`:
„Zur Sammlung hinzufügen" (Akzent-Outline `rgba(126,226,184,.55)`, Text `#8fe3c1`, Hover `rgba(126,226,184,.14)`) =
gleiche Aktion wie ♥; „Überspringen" (neutrale Outline) = gleiche Aktion wie ✕.
#### A2 Detailansicht (Overlay)
Ein Klick auf die Kachel öffnet sie — Pinterests Pin-Seite als Overlay.
- Backdrop: `position:fixed; inset:0; background:rgba(0,0,0,.6)`, Inhalt zentriert,
`padding:32px`, `z-index:60`, Einblendung `fadeIn .16s`. Klick auf den Backdrop schließt.
- Karte: `width:min(1016px,100%)`, `max-height:calc(100vh - 64px)`, `background:#ffffff`,
`border-radius:32px`, `overflow:hidden`, `grid-template-columns:1fr 1fr`, `riseIn .2s`.
- **Schließen**: 40px Kreis `#ffffff` mit `box-shadow: 0 1px 8px rgba(0,0,0,.2)`,
absolut `left:16px; top:16px` über dem Medium. Zusätzlich `Escape`.
- **Links**: das Medium, `min-height:520px`, Verlauf + Streifen wie auf der Kachel.
- **Rechts**: `padding:32px`, `overflow:auto`, Flex-Spalte `gap:20px`:
- Aktionszeile rechtsbündig: „Nicht mehr zeigen" (`#efefef`, 48px hoch, `radius:24px`, 15px/700)
und die Merken-Pille (48px hoch, `padding:0 24px`, `radius:24px`, rot bzw. schwarz wie auf der Kachel).
- Titel = Caption, 32px/700, `letter-spacing:-.02em`.
- Creator-Zeile: 48px Avatar-Kreis, Name 16px/700, Untertitel 14px `#767676`.
- Beschreibung 16px, `line-height:1.6`, `#333333`.
- Statistik-Raster 3 Spalten, `gap:10px`: Kachel `background:#f5f5f5`, `radius:16px`, `padding:14px`;
Wert 18px/700 `tabular-nums`, Label 12px `#767676`.
- Tags: Pills `padding:8px 14px`, `radius:18px`, `background:#efefef`, 13px/600.
#### A3 Auswahlmodus
Für das Abarbeiten ganzer Strecken. Umschalter sitzt in der Tab-Leiste.
- Im Auswahlmodus **verschwinden Merken-Pille und ⋯** — auf der Kachel zählt nur noch die Auswahl.
- Jede Kachel bekommt oben links (`left:12px; top:12px`) einen **Auswahlkreis** 28px,
`border:2px solid #ffffff`: nicht gewählt `background:rgba(0,0,0,.35)`, Haken unsichtbar;
gewählt `background:#e60023` mit weißem ✓ 15px/700. `pointer-events:none` — geklickt wird die Kachel.
- Gewählte Kachel zusätzlich `outline:4px solid #e60023; outline-offset:-4px`.
- Ein Klick auf die Kachel wählt aus bzw. ab (öffnet **nicht** die Detailansicht).
- **Auswahlleiste**, sobald mindestens eine Kachel gewählt ist: fixiert `bottom:28px`, zentriert,
`padding:12px 12px 12px 22px`, `radius:32px`, `background:#ffffff`,
`box-shadow: 0 1px 20px rgba(0,0,0,.22)`, `riseIn .2s`, `z-index:45`. Inhalt:
„**3 ausgewählt**" 15px/700, roter Button „Zur Sammlung hinzufügen", grauer „Auswahl aufheben"
(beide `padding:12px 20px`, `radius:24px`, 15px/700).
- „Zur Sammlung hinzufügen" merkt alle gewählten Karten, leert die Auswahl und **verlässt den
Auswahlmodus**; Toast „N Karten zur Sammlung hinzugefügt".
- `Escape` verlässt den Auswahlmodus und verwirft die Auswahl.
### B. Profil
- `padding:38px 44px`, `overflow:auto`, Einblendung `riseIn .26s`.
- Kopf: 96px Avatar-Kreis (`linear-gradient(140deg,#b2323c,#5d1620)`, `border:2px solid rgba(255,255,255,.2)`),
Name 32px/600 `letter-spacing:-.02em`, Meta „Studio-Konto · seit März 2026" 14px `rgba(242,239,233,.6)`;
rechts drei Kennzahlen (`gap:34px`): Posts 38, Gemerkt (live aus dem Feed), Follower 1.204 —
Werte 22px JetBrains Mono tabular-nums, Labels 12px. Unterkante Hairline `rgba(255,255,255,.09)`, `padding-bottom:26px`.
- Galerie: `grid-template-columns: repeat(4,1fr)`, `gap:18px`, `margin-top:28px`.
Kachel: Höhe 230px, `radius:16px`, `border:1px solid rgba(255,255,255,.12)`, Kartenverlauf + Streifen (`opacity:.45`),
Titel unten auf `linear-gradient(0deg, rgba(0,0,0,.7), transparent)`, 13px/600, `padding:14px`.
Hover: `translateY(-4px)`, Border `rgba(126,226,184,.5)`, Transition `.2s`.
- `padding:32px 24px 40px`, `overflow:auto`, Einblendung `riseIn .26s`.
- Kopf **zentriert** als Spalte, `gap:10px`, `padding-bottom:28px`: 120px Avatar-Kreis
(`linear-gradient(140deg,#b2323c,#5d1620)`, ohne Rand), Name 36px/700 `letter-spacing:-.02em`,
Meta „Studio-Konto · seit März 2026" 15px `#5f5f5f`; darunter drei Kennzahlen (`gap:28px`,
zentriert): Posts 38, **Gemerkt (live: Anzahl der gemerkten Karten)**, Follower 1.204 —
Werte 18px/700 `tabular-nums`, Labels 13px `#767676`.
- **Die Galerie zeigt genau die gemerkten Karten** — dasselbe Masonry-Raster wie im Feed,
aber ohne Hover-Steuerung. Ein Klick öffnet die Detailansicht.
### C. Erstellen
- `padding:38px 44px`; Inhalt `max-width:920px`, `grid-template-columns: 1fr 320px`, `gap:34px`.
- Titel „Neuen Post erstellen" 30px/600, Untertitel 14px `rgba(242,239,233,.6)`.
- **Dropzone**: Höhe 220px, `border:1px dashed rgba(255,255,255,.24)`, `radius:18px`,
`background: rgba(255,255,255,.03)`; Hover Border `rgba(126,226,184,.6)`, BG `rgba(126,226,184,.06)`.
Text „Dateien hierher ziehen" 15px/600 + „MP4, MOV oder bis zu 10 Bilder" 13px.
- **Felder** Titel und Untertitel: Label 12px uppercase `letter-spacing:.14em` `rgba(242,239,233,.5)`;
Input `padding:13px 15px`, `radius:11px`, `border:1px solid rgba(255,255,255,.16)`,
`background: rgba(0,0,0,.24)`, Text `#f2efe9`, 15px. Beide sind an die Live-Vorschau gebunden.
- **Art**: Segmented Pills „Produkt / Video / Serie", `padding:10px 18px`, `radius:999px`;
aktiv `border rgba(126,226,184,.6)`, `background rgba(126,226,184,.12)`, Text `#a9f0d0`.
- **Aktionen**: „Veröffentlichen" (Akzent-Outline, löst Toast „Post veröffentlicht" aus),
„Als Entwurf sichern" (neutrale Outline), beide `padding:13px 24px`, `radius:12px`.
- **Vorschau** (rechte Spalte): Kicker + Karte 420px hoch, `radius:22px`,
`linear-gradient(155deg,#1f8163,#0d4034)` + `repeating-linear-gradient(48deg, rgba(255,255,255,.07) 0 2px, transparent 2px 15px)`,
unten Titel 18px/700 und Untertitel 13px auf schwarzem Verlauf — spiegelt die Eingaben live.
- `padding:32px 24px 40px`; Inhalt in einer weißen Karte: `max-width:920px`, zentriert,
`radius:24px`, `box-shadow: 0 1px 20px rgba(0,0,0,.12)`, `padding:32px`,
`grid-template-columns: 1fr 320px`, `gap:32px`.
- Titel „Neuen Post erstellen" 30px/700, Untertitel 15px `#5f5f5f`.
- **Dropzone**: Höhe 220px, `border:2px dashed #cdcdcd`, `radius:16px`, `background:#f5f5f5`;
Hover Border `#e60023`, BG `#fafafa`.
Text „Dateien hierher ziehen" 16px/700 + „MP4, MOV oder bis zu 10 Bilder" 14px `#767676`.
- **Felder** Titel und Untertitel: Label 13px/700 `#111111`; Input `padding:14px 16px`,
`radius:16px`, `border:2px solid #cdcdcd`, `background:#ffffff`, 15px.
Beide sind an die Live-Vorschau gebunden.
- **Art**: Pills „Produkt / Video / Serie", `padding:11px 18px`, `radius:22px`, `font:700 15px`;
aktiv `background:#111111`, Text `#ffffff`; inaktiv `background:#efefef`, Text `#111111`.
- **Aktionen**: „Veröffentlichen" (rot `#e60023`, löst Toast „Post veröffentlicht" aus),
„Als Entwurf sichern" (`#efefef`), beide `padding:14px 24px`, `radius:24px`, `font:700 15px`.
- **Vorschau** (rechte Spalte): Kicker 12px/600 `#767676` + Karte 420px hoch, `radius:16px`,
Verlauf + Streifen, unten Titel 18px/700 und Untertitel 14px auf schwarzem Verlauf —
spiegelt die Eingaben live.
### D. Einstellungen
- `padding:38px 44px`, Inhalt `max-width:660px`. Titel „Einstellungen" 30px/600.
- Liste: Container `border:1px solid rgba(255,255,255,.1)`, `radius:16px`, `overflow:hidden`.
Zeile `padding:18px 20px`, `background: rgba(255,255,255,.02)`, Trenner `1px solid rgba(255,255,255,.07)`;
Label 15px/600, Hinweis 13px `rgba(242,239,233,.55)`.
- Toggle: Track 52×30, `radius:999px`; aus → `background rgba(255,255,255,.05)`, `border rgba(255,255,255,.2)`,
Knopf links 3px, `rgba(242,239,233,.55)`; an → `background rgba(126,226,184,.18)`,
`border rgba(126,226,184,.6)`, Knopf links 25px, `#8fe3c1`. Knopf 22px, Transition
`left .2s cubic-bezier(.22,.9,.3,1), background .2s`.
- Einträge: Videos automatisch abspielen (an), Ton beim Öffnen (aus), Wochenrückblick (an), Bewegte Übergänge (an).
- `padding:32px 24px 40px`, Inhalt `max-width:660px`, zentriert. Titel „Einstellungen" 30px/700.
- Liste: Container `radius:16px`, `overflow:hidden`, `box-shadow: 0 1px 20px rgba(0,0,0,.12)`,
**kein Rahmen**. Zeile `padding:20px`, `background:#ffffff`, Trenner `1px solid #efefef`;
Label 16px/700, Hinweis 14px `#767676`.
- Toggle: Track 52×30, `radius:999px`, kein Rahmen; aus `#cdcdcd`, an `#e60023`.
Knopf 24px, immer `#ffffff` mit `box-shadow: 0 1px 3px rgba(0,0,0,.25)`, links 3px → 25px,
Transition `left .2s cubic-bezier(.22,.9,.3,1)`.
- Einträge: Videos automatisch abspielen (an), Ton beim Öffnen (aus), Wochenrückblick (an),
Bewegte Übergänge (an).
---
## Interaktion & Verhalten
**Karte entscheiden — drei gleichwertige Wege:**
1. **Maus-Drag**: Pointer Events auf der obersten Karte (`pointerdown` → `setPointerCapture`,
`pointermove` → Delta, `pointerup`/`pointercancel` → Auswertung).
Live-Transform: `translate3d(dx, dy, 0) rotate(dx/22 deg)`, Transition währenddessen aus.
Schwelle **±120px**: `dx > 120` = merken, `dx < -120` = ablehnen, sonst Rücksprung (Transition wieder an).
2. **Buttons** ✕ / ↺ / ♥ unter der Bühne (und die beiden Buttons in der Detailspalte).
3. **Tastatur** (global, nur auf dem Feed-Screen, mit `preventDefault`):
`ArrowLeft` = ablehnen, `ArrowRight` = merken, `ArrowUp` = zurücknehmen.
**Merken** — zwei Wege, beide schalten um (zweiter Klick nimmt zurück):
1. Merken-Pille auf der Kachel (erscheint beim Hover).
2. Merken-Button in der Detailansicht.
Toast: „Zu <Name>' gemerkt" bzw. „Aus der Sammlung entfernt".
**Exit-Animation:** `transform: translate3d(±620px, -40px, 0) rotate(±22deg)`, `opacity: 0`,
Dauer **320ms**, danach Index +1 und `exiting` zurücksetzen. Während `exiting` sind weitere
Entscheidungen gesperrt.
**Ausblenden** — „Nicht mehr zeigen" im ⋯-Menü oder in der Detailansicht. Die Kachel verschwindet
aus dem Raster, eine offene Detailansicht schließt sich, Toast „Wird nicht mehr gezeigt".
**Undo:** letzte Entscheidung aus der History, Index zurück, „Gemerkt"-Zähler ggf. 1.
Leere History → Toast „Nichts zurückzunehmen".
**Öffnen** — Klick auf die Kachel oder „Details öffnen" im ⋯-Menü.
**Toast:** fixiert `bottom:34px`, horizontal zentriert, `padding:12px 22px`, `radius:999px`,
`background: rgba(10,24,20,.92)`, `blur(10px)`, `border:1px solid rgba(255,255,255,.16)`, 14px,
Einblendung `riseIn .2s`, verschwindet nach **1700ms**. Texte: „Zu <Name>' gemerkt",
„Übersprungen", „Nichts zurückzunehmen", „Post veröffentlicht".
**Mehrfachauswahl** — siehe [A3](#a3-auswahlmodus).
**Tastatur:** `Escape` schließt der Reihe nach: offene Detailansicht → offenes ⋯-Menü →
Auswahlmodus. Weitere Kürzel gibt es nicht mehr; die Pfeiltasten sind mit dem Swipen entfallen.
**Hover-Zustände:** Scrim, Merken-Pille und ⋯ hängen am Hover der *Kachel*, nicht am eigenen Element
(im Prototyp `.pin:hover .pin-hover`, zusätzlich `:focus-within` für Tastaturbedienung).
Im Ziel-Framework als Hover-State der Kartenkomponente umsetzen — und den Fokusfall nicht vergessen,
sonst sind Merken und ⋯ per Tastatur nicht erreichbar.
**Toast:** fixiert, horizontal zentriert, `bottom:28px` — bei sichtbarer Auswahlleiste `96px`,
damit beide nicht übereinanderliegen. `padding:16px 22px`, `radius:16px`, `background:#ffffff`,
`box-shadow: 0 1px 20px rgba(0,0,0,.22)`, 15px/600, `riseIn .2s`, verschwindet nach **1700ms**.
**Keyframes:**
- `riseIn`: `opacity:0; translateY(14px)` → `opacity:1; none` (.2.26s ease)
- `riseIn`: `opacity:0; translateY(14px)` → `opacity:1; none` (.14.26s ease)
- `fadeIn`: `opacity:0` → `1`
- Fokus: `:focus-visible { outline: 2px solid #7ee2b8; outline-offset: 2px; }` — nie der Browser-Default.
- Fokus: `:focus-visible { outline: 2px solid #0074e8; outline-offset: 2px; }` — nie der Browser-Default.
**Responsive:** Desktop-only, `min-width:1180px`; darunter horizontal scrollen.
Der Feed-Bereich scrollt bei geringer Fensterhöhe (`min-height:0; overflow:auto`).
---
@@ -205,22 +226,23 @@ Der Feed-Bereich scrollt bei geringer Fensterhöhe (`min-height:0; overflow:auto
| State | Typ | Zweck |
|---|---|---|
| `screen` | `'feed' \| 'profile' \| 'create' \| 'settings'` | aktive Ansicht (Sidebar) |
| `tab` | `'videos' \| 'following' \| 'posts'` | aktiver Stapel; Wechsel setzt `idx`/`history` zurück und springt auf `feed` |
| `idx` | number | Index der obersten Karte im aktuellen Stapel |
| `drag` | `{x, y, active} \| null` | laufende Drag-Geste |
| `exiting` | `'left' \| 'right' \| null` | laufende Exit-Animation (sperrt Eingaben) |
| `history` | `{idx, dir}[]` | für Undo |
| `seen`, `saved` | number | Zähler Sidebar / Profil |
| `showInfo` | boolean | Kürzel-Panel |
| `screen` | `'feed' \| 'profile' \| 'create' \| 'settings'` | aktive Ansicht (Icon-Rail) |
| `tab` | `'videos' \| 'following' \| 'posts'` | aktives Raster; Wechsel verwirft Auswahl und Menü |
| `saved` | `string[]` (Karten-IDs) | gemerkte Karten — speist Profil-Galerie und Kennzahl |
| `hidden` | `string[]` (Karten-IDs) | ausgeblendete Karten, aus dem Raster gefiltert |
| `selectMode` | boolean | Auswahlmodus aktiv |
| `selection` | `string[]` (Karten-IDs) | aktuell gewählte Kacheln |
| `detail` | `string \| null` | ID der offenen Detailansicht |
| `menuFor` | `string \| null` | ID der Kachel mit offenem ⋯-Menü |
| `toast` | string | Toast-Text (leer = versteckt) |
| `draftTitle`, `draftSub`, `draftKind` | string | Formular „Erstellen", live in der Vorschau |
| `settings` | `{autoplay, sound, digest, motion}` booleans | Einstellungen |
**Datenbedarf pro Karte:** `name`, `sub`, `kind`, `assets`, `caption`, `hue: [from, to]`,
**Datenbedarf pro Karte:** `id`, `name`, `sub`, `kind`, `assets`, `caption`, `hue: [from, to]`,
`avatar` (Verlauf oder Bild-URL), `body`, `tags: string[]`, `stats: [wert, label][]`.
Im Produkt ersetzt das Medium (Bild/Video) die Verlaufs-/Streifenfläche; alles andere bleibt.
Die drei Tabs liefern drei Stapel (Videos, Folge ich, Posts).
Die `id` ist neu und trägt jetzt alles — Merken, Ausblenden, Auswahl und Detailansicht referenzieren
Karten darüber, nicht mehr über einen Index. Im Produkt ersetzt das Medium (Bild/Video) die
Verlaufs-/Streifenfläche. Die drei Tabs liefern drei Raster.
---
@@ -229,33 +251,49 @@ Die drei Tabs liefern drei Stapel (Videos, Folge ich, Posts).
**Farben**
| Rolle | Wert |
|---|---|
| Seitenverlauf | `#0d4c3d` → `#0a3a30` → `#24121a` → `#40161b` |
| Text | `#f2efe9` |
| Text gedämpft | `rgba(242,239,233,.62)` / `.55` / `.5` / `.45` |
| Akzent (Mint) | `#8fe3c1`, hell `#a9f0d0` / `#d6fbec`, Fokus `#7ee2b8` |
| Akzent-Tint | `rgba(126,226,184,.12)` Fläche, `.4.6` Rahmen, `.14` Hover |
| Ablehnen | `rgba(232,120,120,.9)` Rahmen, `#f0a8a8` Text, `rgba(120,32,38,.5)` Hover |
| Flächen | `rgba(8,26,22,.42)` Sidebar, `rgba(8,22,19,.34)` Detailspalte, `rgba(255,255,255,.02.09)` |
| Rahmen | `rgba(255,255,255,.07)` / `.1` / `.12` / `.16` / `.18` / `.22` |
| Seite | `#ffffff` |
| Text | `#111111` |
| Text gedämpft | `#5f5f5f` (sekundär), `#767676` (Labels, inaktive Tabs), `#333333` (Beschreibung) |
| Akzent (Rot) | `#e60023`, Hover `#ad081b`; auch Auswahl-Outline und Auswahlkreis |
| Dunkel | `#111111` (aktiver Rail-Button, aktive Art-Pille, **gemerkt**-Zustand, „Fertig"), Hover `#000000` |
| Neutrale Flächen | `#efefef` (Suchfeld, Buttons, Tags), Hover `#e2e2e2`; `#e9e9e9` (Rail-/⋯-Hover); `#f5f5f5` (Statistik-Kacheln, Dropzone) |
| Linien | `#efefef` (Trenner, Kanten), `#cdcdcd` (Eingabe-Rahmen, Toggle aus) |
| Auf Medien | `rgba(255,255,255,.82)` Untertitel, `rgba(0,0,0,.24)` Hover-Scrim, `rgba(0,0,0,.62)` Fußzeilen-Verlauf, `rgba(0,0,0,.35)` Auswahlkreis leer |
| Overlay | `rgba(0,0,0,.6)` Backdrop der Detailansicht |
| Fokus | `#0074e8` |
| Karten-Verläufe | grün `#1f8163#0d4034`, blau `#256f8b#0c2f3f`, ocker `#8a6a2f#3a2a11`, rot `#7a2f38#2c1014`, tannen `#2f6f5a#0e332b`, türkis `#1c6f7f#0a2c33`, violett `#6a2f7a#26102c`, gold `#7a5a2a#2f2110` |
**Typografie** — Manrope 400/500/600/700 (UI), JetBrains Mono 400/500 (Zahlen, Captions).
Skala: 32 / 30 / 26 / 22 / 19 / 17 / 16 / 15 / 14 / 13 / 12 / 11 px.
Große Überschriften `letter-spacing:-.02em`, Kicker `+.14em`/`.16em` uppercase.
Alle Zahlen mit `font-variant-numeric: tabular-nums`.
**Typografie** — **nur Manrope** 400/500/600/700. Zahlen mit `font-variant-numeric: tabular-nums`.
Skala: 36 / 32 / 30 / 20 / 18 / 17 / 16 / 15 / 14 / 13 / 12 px.
Große Überschriften `letter-spacing:-.02em`. Kicker sind 12px/600 in `#767676`, normal geschrieben.
**Abstände** 3 / 4 / 6 / 8 / 9 / 10 / 12 / 13 / 14 / 18 / 20 / 22 / 26 / 28 / 34 / 38 / 44 px.
**Radien** 9 / 11 / 12 / 14 / 16 / 18 / 22 / 26 px, Pills `999px`, Kreise `50%`.
**Schatten** nur an der Karte: `0 26px 60px rgba(0,0,0,.45)`.
**Blur** 4 / 6 / 10 / 12 / 14 px (`backdrop-filter`).
**Transitions** `.16s` / `.18s` / `.2s` / `.3s` ease, Karten-Bewegung `.34s cubic-bezier(.22,.9,.3,1)`, Exit `320ms`.
**Abstände** 2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 22 / 24 / 28 / 32 / 40 / 56 / 96 px.
**Radien** 12px (Menüeinträge), 16px (Kacheln, Eingaben, Toast, Menü), 18 / 22 / 24px (Pillen),
32px (Detailansicht, Auswahlleiste), `50%` Kreise, `999px` Toggle-Track.
**Schatten** `0 1px 8px rgba(0,0,0,.2)` schwebende Rundbuttons · `0 1px 20px rgba(0,0,0,.12)`
Flächen/Listen · `.22` Menü, Auswahlleiste, Toast · `0 1px 3px rgba(0,0,0,.25)` Toggle-Knopf.
**Kein `backdrop-filter`** — die Oberfläche ist deckend.
**Transitions** `.12s ease` (Hover, Farbwechsel, Ein-/Ausblenden), `.2s` (Toggle).
Karten- und Exit-Animationen des alten Stapels entfallen ersatzlos.
---
## Assets
Keine Binärdateien. Alle Medien sind Platzhalter aus CSS-Verläufen und Streifenmustern —
im Produkt durch echte Bilder/Videos ersetzen. Icons: **Lucide** (im Prototyp Unicode-Glyphen als Platzhalter).
Fonts: Google Fonts — Manrope, JetBrains Mono.
Keine Binärdateien im Prototyp. Alle Medien sind Platzhalter aus CSS-Verläufen und Streifenmustern —
im Produkt durch echte Bilder/Videos ersetzen. Icons: **Lucide** (im Prototyp Unicode-Glyphen als
Platzhalter). Fonts: Google Fonts — **nur Manrope**.
## Offene Punkte
- **Suchfeld ohne Funktion.** Die Pinterest-Kopfzeile lebt von der Suche, im Prototyp ist sie eine
reine Attrappe — es gibt bisher keine Suchfunktion in der App. Vor der Umsetzung klären, ob Suche
gebaut wird; wenn nicht, gehört das Feld raus, statt eine Funktion vorzutäuschen.
- **Ausblenden ist im Prototyp flüchtig.** `hidden` lebt nur im Zustand der Seite. Im Produkt muss
entschieden werden, ob „Nicht mehr zeigen" dauerhaft pro Konto gilt und ob es einen Weg zurück
gibt (im Prototyp nur über den Leerzustand).
- **Nachladen fehlt.** Das Raster zeigt einen endlichen Satz. Für echte Datenmengen braucht es
Infinite Scroll oder Paginierung samt Ladeplatzhaltern — beides ist hier nicht gestaltet.
- **Pinterest-Referenz liegt nicht im Repo.** Die Vorlage kam als Screenshot im Chat. Wer sie
dauerhaft braucht, legt sie als `referenz/pinterest-desktop.png` daneben.
## Dateien in diesem Bundle
- `PROMPT.md` — fertiger Auftrag zum Einfügen in Claude Code.

View File

@@ -10,190 +10,138 @@
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
html, body { margin:0; padding:0; background:#120b0d; }
html, body { margin:0; padding:0; background:#ffffff; }
* { box-sizing: border-box; }
a { color:#8fe3c1; text-decoration:none; }
a:hover { color:#b9f2da; }
::selection { background:rgba(126,226,184,.28); }
button:focus-visible, [tabindex]:focus-visible { outline:2px solid #7ee2b8; outline-offset:2px; }
a { color:#111111; text-decoration:none; }
::selection { background:rgba(230,0,35,.16); }
button:focus-visible, [tabindex]:focus-visible, input:focus-visible { outline:2px solid #0074e8; outline-offset:2px; }
@keyframes riseIn { from { opacity:0; transform:translateY(14px);} to { opacity:1; transform:none;} }
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
@keyframes pulseRing { 0%{ box-shadow:0 0 0 0 rgba(126,226,184,.35)} 70%{ box-shadow:0 0 0 18px rgba(126,226,184,0)} 100%{ box-shadow:0 0 0 0 rgba(126,226,184,0)} }
/* Hover-Steuerung haengt an der Kachel, nicht am Element selbst. */
.pin-hover { opacity:0; transition:opacity .12s ease; }
.pin:hover .pin-hover, .pin:focus-within .pin-hover { opacity:1; }
.pin-scrim { opacity:0; transition:opacity .12s ease; }
.pin:hover .pin-scrim { opacity:1; }
/* Rail-Zustaende als Klassen: das Runtime setzt style-hover mit !important
und wuerde ein inline gesetztes Aktiv-Schwarz beim Hover ueberschreiben. */
.rail { background:transparent; color:#111111; }
.rail:hover { background:#e9e9e9; }
.rail.active { background:#111111; color:#ffffff; }
.rail.active:hover { background:#000000; }
</style>
</helmet>
<div style="min-height:100vh; width:100%; min-width:1180px; display:flex; font-family:'Manrope',system-ui,sans-serif; color:#f2efe9; background:radial-gradient(120% 80% at 50% 0%, #0d4c3d 0%, #0a3a30 34%, #24121a 68%, #40161b 100%); background-attachment:fixed;">
<div style="min-height:100vh; width:100%; min-width:1180px; display:flex; font-family:'Manrope',system-ui,sans-serif; color:#111111; background:#ffffff;">
<aside style="width:236px; flex:none; display:flex; flex-direction:column; gap:28px; padding:26px 20px; border-right:1px solid rgba(255,255,255,.08); background:rgba(8,26,22,.42); backdrop-filter:blur(14px); position:sticky; top:0; height:100vh;">
<div style="display:flex; align-items:center; gap:10px;">
<div style="width:30px; height:30px; border-radius:9px; background:linear-gradient(150deg,#2fa87f,#0e5442); display:grid; place-items:center; font-size:15px; font-weight:700;">V</div>
<div style="font-size:16px; font-weight:600; letter-spacing:-.01em;">videogen</div>
<aside style="width:80px; flex:none; display:flex; flex-direction:column; align-items:center; gap:8px; padding:12px 0 16px; border-right:1px solid #efefef; background:#ffffff; position:sticky; top:0; height:100vh;">
<div style="width:48px; height:48px; display:grid; place-items:center; margin-bottom:4px;">
<div style="width:32px; height:32px; border-radius:50%; background:#e60023; display:grid; place-items:center; color:#ffffff; font-size:17px; font-weight:700;">V</div>
</div>
<nav style="display:flex; flex-direction:column; gap:4px;">
<sc-for list="{{ navItems }}" as="item" hint-placeholder-count="4">
<button onClick="{{ item.go }}" style="{{ item.style }}" style-hover="background:rgba(255,255,255,.09); color:#ffffff;">
<span style="width:20px; display:grid; place-items:center; opacity:.9;">{{ item.glyph }}</span>
<span>{{ item.label }}</span>
</button>
<nav style="display:flex; flex-direction:column; align-items:center; gap:8px;">
<sc-for list="{{ navTop }}" as="item" hint-placeholder-count="3">
<button onClick="{{ item.go }}" title="{{ item.label }}" class="{{ item.cls }}" style="{{ item.style }}">{{ item.glyph }}</button>
</sc-for>
</nav>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:14px;">
<div style="border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:14px; background:rgba(255,255,255,.03);">
<div style="font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:rgba(242,239,233,.5); margin-bottom:8px;">Heute</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:22px; font-variant-numeric:tabular-nums;">{{ swipesToday }}</div>
<div style="font-size:12px; color:rgba(242,239,233,.55);">Karten gesichtet</div>
</div>
<div style="display:flex; align-items:center; gap:10px; padding:8px 6px;">
<div style="width:32px; height:32px; border-radius:50%; background:linear-gradient(140deg,#b2323c,#5d1620); flex:none;"></div>
<div style="min-width:0;">
<div style="font-size:13px; font-weight:600;">Jonas K.</div>
<div style="font-size:11px; color:rgba(242,239,233,.5);">Studio-Konto</div>
</div>
</div>
<div style="margin-top:auto; display:flex; flex-direction:column; align-items:center; gap:10px;">
<sc-for list="{{ navBottom }}" as="item" hint-placeholder-count="1">
<button onClick="{{ item.go }}" title="{{ item.label }}" class="{{ item.cls }}" style="{{ item.style }}">{{ item.glyph }}</button>
</sc-for>
</div>
</aside>
<main style="flex:1; min-width:0; display:flex; flex-direction:column;">
<header style="height:72px; flex:none; display:flex; align-items:center; justify-content:space-between; padding:0 34px; border-bottom:1px solid rgba(255,255,255,.07);">
<div style="display:flex; gap:6px; align-items:center;">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3">
<button onClick="{{ t.go }}" style="{{ t.style }}" style-hover="color:#ffffff;">{{ t.label }}</button>
</sc-for>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<div style="font-size:12px; color:rgba(242,239,233,.5); font-family:'JetBrains Mono',monospace; font-variant-numeric:tabular-nums;">{{ counter }}</div>
<button onClick="{{ toggleInfo }}" title="Kürzel anzeigen" style="width:36px; height:36px; border-radius:50%; border:1px solid rgba(255,255,255,.18); background:rgba(255,255,255,.04); color:#f2efe9; font-size:14px; cursor:pointer; transition:background .18s ease, transform .18s ease;" style-hover="background:rgba(255,255,255,.12); transform:translateY(-1px);">i</button>
<header style="flex:none; display:flex; align-items:center; gap:8px; padding:12px 16px 4px;">
<div style="flex:1; min-width:0; display:flex; align-items:center; gap:12px; height:48px; padding:0 16px; border-radius:24px; background:#efefef; color:#767676; font-size:16px;">
<span style="font-size:15px;"></span>
<span style="flex:1; min-width:0;">Suchen</span>
<span style="font-size:15px;"></span>
</div>
<button title="Konto" style="width:40px; height:40px; flex:none; border:none; border-radius:50%; background:linear-gradient(140deg,#b2323c,#5d1620); color:#ffffff; font-size:15px; font-weight:700; cursor:pointer; display:grid; place-items:center;">J</button>
<button title="Konto-Menü" style="width:28px; height:28px; flex:none; border:none; border-radius:50%; background:transparent; color:#5f5f5f; font-size:12px; cursor:pointer;" style-hover="background:#e9e9e9;"></button>
</header>
<div style="flex:none; display:flex; align-items:flex-end; justify-content:space-between; gap:20px; padding:0 24px; border-bottom:1px solid #efefef;">
<div style="display:flex; gap:24px; align-items:flex-end;">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="3">
<button onClick="{{ t.go }}" style="{{ t.style }}" style-hover="color:#111111;">{{ t.label }}</button>
</sc-for>
</div>
<div style="display:flex; align-items:center; gap:12px; padding-bottom:8px;">
<div style="font-size:13px; color:#767676;">{{ counter }}</div>
<button onClick="{{ toggleSelectMode }}" style="{{ selectToggleStyle }}">{{ selectToggleLabel }}</button>
</div>
</div>
<sc-if value="{{ isFeed }}" hint-placeholder-val="{{ true }}">
<section style="flex:1; min-height:0; overflow:auto; display:grid; grid-template-columns:minmax(0,1fr) 372px; gap:0;">
<div style="position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:26px 20px 30px; gap:22px; min-width:0; min-height:min-content;">
<div style="position:relative; width:min(404px,100%); height:560px; flex:none;">
<sc-for list="{{ stack }}" as="c" hint-placeholder-count="3">
<div style="{{ c.shell }}" onPointerDown="{{ c.down }}" onPointerMove="{{ c.move }}" onPointerUp="{{ c.up }}" onPointerCancel="{{ c.up }}">
<div style="{{ c.media }}">
<div style="position:absolute; inset:0; background:{{ c.stripes }}; opacity:.5;"></div>
<div style="position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,.32) 0%, rgba(0,0,0,0) 26%, rgba(0,0,0,0) 52%, rgba(0,0,0,.66) 100%);"></div>
<div style="position:relative; display:flex; gap:9px; padding:18px;">
<span style="padding:7px 15px; border-radius:999px; border:1px solid rgba(255,255,255,.34); background:rgba(0,0,0,.22); backdrop-filter:blur(6px); font-size:13px; font-weight:600;">{{ c.kind }}</span>
<span style="padding:7px 15px; border-radius:999px; border:1px solid rgba(255,255,255,.34); background:rgba(0,0,0,.22); backdrop-filter:blur(6px); font-size:13px; font-weight:600;">{{ c.assets }}</span>
<section style="flex:1; min-height:0; overflow:auto; padding:24px 24px 96px;">
<div style="{{ gridStyle }}">
<sc-for list="{{ grid }}" as="c" hint-placeholder-count="10">
<div class="pin" style="position:relative; break-inside:avoid; margin-bottom:16px;">
<div onClick="{{ c.activate }}" style="{{ c.media }}">
<div style="position:absolute; inset:0; background:{{ c.stripes }}; opacity:.45;"></div>
<div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div>
<div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.62), transparent); color:#ffffff;">
<div style="font-size:14px; font-weight:700; text-wrap:pretty;">{{ c.caption }}</div>
<div style="font-size:12px; color:rgba(255,255,255,.82); margin-top:2px;">{{ c.name }} · {{ c.kind }}</div>
</div>
<div style="{{ c.checkbox }}"></div>
</div>
<div style="position:absolute; inset:auto 0 118px 0; display:flex; justify-content:center; padding:0 22px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:15px; padding:9px 16px; border-radius:9px; background:rgba(0,0,0,.5); backdrop-filter:blur(4px); text-align:center; text-wrap:pretty;">{{ c.caption }}</div>
<div class="pin-hover" style="{{ c.saveSlot }}">
<button onClick="{{ c.toggleSave }}" style="{{ c.saveStyle }}" style-hover="filter:brightness(.88);">{{ c.saveLabel }}</button>
</div>
<div class="pin-hover" style="{{ c.menuSlot }}">
<button onClick="{{ c.openMenu }}" title="Weitere Optionen" style="{{ c.menuBtn }}" style-hover="background:#e9e9e9;"></button>
</div>
<div style="position:absolute; inset:auto 0 0 0; display:flex; align-items:center; gap:13px; padding:20px;">
<div style="width:46px; height:46px; border-radius:50%; flex:none; background:{{ c.avatar }}; border:2px solid rgba(255,255,255,.3);"></div>
<div style="min-width:0;">
<div style="font-size:19px; font-weight:700; letter-spacing:-.01em;">{{ c.name }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.72);">{{ c.sub }}</div>
</div>
</div>
<div style="{{ c.badge }}">{{ c.badgeText }}</div>
</div>
</div>
</sc-for>
<sc-if value="{{ stackEmpty }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; inset:0; border:1px dashed rgba(255,255,255,.2); border-radius:26px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:16px; text-align:center; padding:40px; animation:fadeIn .3s ease;">
<div style="font-size:20px; font-weight:600;">Alles gesichtet</div>
<div style="font-size:14px; color:rgba(242,239,233,.6); max-width:230px; text-wrap:pretty;">Für heute sind keine neuen Karten mehr in dieser Ansicht.</div>
<button onClick="{{ reset }}" style="padding:11px 20px; border-radius:999px; border:1px solid rgba(126,226,184,.6); background:transparent; color:#8fe3c1; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:background .18s ease;" style-hover="background:rgba(126,226,184,.14);">Stapel neu laden</button>
<sc-if value="{{ c.menuOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; right:12px; bottom:56px; width:210px; border-radius:16px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.22); padding:8px; z-index:20; animation:riseIn .14s ease;">
<button onClick="{{ c.hide }}" style="width:100%; text-align:left; padding:12px 14px; border:none; border-radius:12px; background:transparent; color:#111111; font-family:inherit; font-size:15px; font-weight:600; cursor:pointer;" style-hover="background:#efefef;">Nicht mehr zeigen</button>
<button onClick="{{ c.open }}" style="width:100%; text-align:left; padding:12px 14px; border:none; border-radius:12px; background:transparent; color:#111111; font-family:inherit; font-size:15px; font-weight:600; cursor:pointer;" style-hover="background:#efefef;">Details öffnen</button>
</div>
</sc-if>
</div>
<div style="display:flex; align-items:center; gap:18px;">
<button onClick="{{ onNope }}" title="Ablehnen — Pfeil links" style="width:62px; height:62px; border-radius:50%; border:1px solid rgba(255,255,255,.16); background:rgba(14,10,12,.72); color:#f2efe9; font-size:22px; cursor:pointer; display:grid; place-items:center; transition:transform .18s ease, border-color .18s ease, background .18s ease;" style-hover="transform:translateY(-3px) scale(1.05); border-color:rgba(230,120,120,.75); background:rgba(120,32,38,.5);" style-active="transform:scale(.95);"></button>
<button onClick="{{ onUndo }}" title="Zurück — Pfeil hoch" style="width:52px; height:52px; border-radius:50%; border:1px solid rgba(255,255,255,.16); background:rgba(14,10,12,.72); color:#f2efe9; font-size:17px; cursor:pointer; display:grid; place-items:center; transition:transform .18s ease, border-color .18s ease;" style-hover="transform:translateY(-3px) scale(1.05); border-color:rgba(255,255,255,.4);" style-active="transform:scale(.95);"></button>
<button onClick="{{ onLike }}" title="Merken — Pfeil rechts" style="width:70px; height:70px; border-radius:50%; border:1px solid rgba(126,226,184,.45); background:rgba(14,10,12,.72); color:#8fe3c1; font-size:26px; cursor:pointer; display:grid; place-items:center; transition:transform .18s ease, background .18s ease;" style-hover="transform:translateY(-3px) scale(1.06); background:rgba(20,88,68,.6);" style-active="transform:scale(.95);"></button>
</sc-for>
</div>
<sc-if value="{{ showInfo }}" hint-placeholder-val="{{ false }}">
<div style="position:absolute; right:26px; top:26px; width:250px; border:1px solid rgba(255,255,255,.14); border-radius:14px; background:rgba(10,26,22,.9); backdrop-filter:blur(12px); padding:16px; font-size:13px; line-height:1.8; animation:riseIn .22s ease;">
<div style="font-weight:700; margin-bottom:8px;">Bedienung</div>
<div style="display:grid; grid-template-columns:auto 1fr; gap:6px 12px; color:rgba(242,239,233,.72);">
<span style="font-family:'JetBrains Mono',monospace;"></span><span>Ablehnen</span>
<span style="font-family:'JetBrains Mono',monospace;"></span><span>Merken</span>
<span style="font-family:'JetBrains Mono',monospace;"></span><span>Zurücknehmen</span>
<span style="font-family:'JetBrains Mono',monospace;">Maus</span><span>Karte ziehen</span>
</div>
<sc-if value="{{ gridEmpty }}" hint-placeholder-val="{{ false }}">
<div style="max-width:420px; margin:80px auto; text-align:center; display:flex; flex-direction:column; align-items:center; gap:14px; animation:fadeIn .3s ease;">
<div style="font-size:20px; font-weight:700;">Nichts mehr in dieser Ansicht</div>
<div style="font-size:15px; color:#5f5f5f; text-wrap:pretty;">Alle Karten dieses Tabs sind ausgeblendet.</div>
<button onClick="{{ unhideAll }}" style="padding:12px 20px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer;" style-hover="background:#ad081b;">Ausgeblendete wieder anzeigen</button>
</div>
</sc-if>
</div>
<aside style="border-left:1px solid rgba(255,255,255,.07); background:rgba(8,22,19,.34); padding:30px 28px; display:flex; flex-direction:column; gap:24px; overflow:auto;">
<div>
<div style="font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:rgba(242,239,233,.45);">Aktuelle Karte</div>
<div style="font-size:26px; font-weight:600; letter-spacing:-.02em; margin-top:10px; text-wrap:pretty;">{{ detailTitle }}</div>
<div style="font-size:14px; color:rgba(242,239,233,.62); margin-top:6px;">{{ detailSub }}</div>
</div>
<div style="height:1px; background:rgba(255,255,255,.09);"></div>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:12px;">
<sc-for list="{{ detailStats }}" as="s" hint-placeholder-count="3">
<div style="border:1px solid rgba(255,255,255,.1); border-radius:12px; padding:12px;">
<div style="font-family:'JetBrains Mono',monospace; font-size:17px; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:11px; color:rgba(242,239,233,.5); margin-top:3px;">{{ s.label }}</div>
</div>
</sc-for>
</div>
<div>
<div style="font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:rgba(242,239,233,.45); margin-bottom:10px;">Beschreibung</div>
<p style="margin:0; font-size:14px; line-height:1.75; color:rgba(242,239,233,.78); text-wrap:pretty;">{{ detailBody }}</p>
</div>
<div>
<div style="font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:rgba(242,239,233,.45); margin-bottom:10px;">Tags</div>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<sc-for list="{{ detailTags }}" as="tag" hint-placeholder-count="4">
<span style="padding:6px 12px; border-radius:999px; border:1px solid rgba(255,255,255,.18); font-size:12px; color:rgba(242,239,233,.8);">{{ tag }}</span>
</sc-for>
</div>
</div>
<div style="margin-top:auto; display:flex; flex-direction:column; gap:10px;">
<button onClick="{{ onLike }}" style="padding:13px; border-radius:12px; border:1px solid rgba(126,226,184,.55); background:transparent; color:#8fe3c1; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:background .18s ease;" style-hover="background:rgba(126,226,184,.14);">Zur Sammlung hinzufügen</button>
<button onClick="{{ onNope }}" style="padding:13px; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:transparent; color:rgba(242,239,233,.8); font-family:inherit; font-size:14px; cursor:pointer; transition:background .18s ease;" style-hover="background:rgba(255,255,255,.07);">Überspringen</button>
</div>
</aside>
</section>
</sc-if>
<sc-if value="{{ isProfile }}" hint-placeholder-val="{{ false }}">
<section style="flex:1; padding:38px 44px; overflow:auto; animation:riseIn .26s ease;">
<div style="display:flex; align-items:flex-end; gap:22px; padding-bottom:26px; border-bottom:1px solid rgba(255,255,255,.09);">
<div style="width:96px; height:96px; border-radius:50%; background:linear-gradient(140deg,#b2323c,#5d1620); flex:none; border:2px solid rgba(255,255,255,.2);"></div>
<div style="flex:1;">
<div style="font-size:32px; font-weight:600; letter-spacing:-.02em;">Jonas K.</div>
<div style="font-size:14px; color:rgba(242,239,233,.6); margin-top:4px;">Studio-Konto · seit März 2026</div>
</div>
<div style="display:flex; gap:34px; padding-bottom:6px;">
<section style="flex:1; padding:32px 24px 40px; overflow:auto; animation:riseIn .26s ease;">
<div style="display:flex; flex-direction:column; align-items:center; gap:10px; padding-bottom:28px;">
<div style="width:120px; height:120px; border-radius:50%; background:linear-gradient(140deg,#b2323c,#5d1620); flex:none;"></div>
<div style="font-size:36px; font-weight:700; letter-spacing:-.02em; text-align:center;">Jonas K.</div>
<div style="font-size:15px; color:#5f5f5f;">Studio-Konto · seit März 2026</div>
<div style="display:flex; gap:28px; margin-top:6px;">
<sc-for list="{{ profileStats }}" as="s" hint-placeholder-count="3">
<div>
<div style="font-family:'JetBrains Mono',monospace; font-size:22px; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:12px; color:rgba(242,239,233,.5);">{{ s.label }}</div>
<div style="text-align:center;">
<div style="font-size:18px; font-weight:700; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:13px; color:#767676;">{{ s.label }}</div>
</div>
</sc-for>
</div>
</div>
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:18px; margin-top:28px;">
<div style="{{ gridStyle }}">
<sc-for list="{{ gallery }}" as="g" hint-placeholder-count="8">
<div style="{{ g.tile }}" style-hover="transform:translateY(-4px); border-color:rgba(126,226,184,.5);">
<div class="pin" style="position:relative; break-inside:avoid; margin-bottom:16px;">
<div onClick="{{ g.open }}" style="{{ g.media }}">
<div style="position:absolute; inset:0; background:{{ g.stripes }}; opacity:.45;"></div>
<div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.7), transparent); font-size:13px; font-weight:600;">{{ g.title }}</div>
<div class="pin-scrim" style="position:absolute; inset:0; background:rgba(0,0,0,.24);"></div>
<div style="position:absolute; inset:auto 0 0 0; padding:14px; background:linear-gradient(0deg, rgba(0,0,0,.62), transparent); color:#ffffff; font-size:14px; font-weight:600;">{{ g.caption }}</div>
</div>
</div>
</sc-for>
</div>
@@ -201,47 +149,47 @@
</sc-if>
<sc-if value="{{ isCreate }}" hint-placeholder-val="{{ false }}">
<section style="flex:1; overflow:auto; padding:38px 44px; animation:riseIn .26s ease;">
<div style="max-width:920px; display:grid; grid-template-columns:1fr 320px; gap:34px;">
<div style="display:flex; flex-direction:column; gap:22px;">
<section style="flex:1; overflow:auto; padding:32px 24px 40px; animation:riseIn .26s ease;">
<div style="max-width:920px; margin:0 auto; border-radius:24px; box-shadow:0 1px 20px rgba(0,0,0,.12); padding:32px; display:grid; grid-template-columns:1fr 320px; gap:32px;">
<div style="display:flex; flex-direction:column; gap:20px;">
<div>
<div style="font-size:30px; font-weight:600; letter-spacing:-.02em;">Neuen Post erstellen</div>
<div style="font-size:14px; color:rgba(242,239,233,.6); margin-top:6px;">Material hochladen, beschriften, in den Feed geben.</div>
<div style="font-size:30px; font-weight:700; letter-spacing:-.02em;">Neuen Post erstellen</div>
<div style="font-size:15px; color:#5f5f5f; margin-top:6px;">Material hochladen, beschriften, in den Feed geben.</div>
</div>
<div style="border:1px dashed rgba(255,255,255,.24); border-radius:18px; height:220px; display:grid; place-items:center; text-align:center; gap:8px; background:rgba(255,255,255,.03); cursor:pointer; transition:border-color .18s ease, background .18s ease;" style-hover="border-color:rgba(126,226,184,.6); background:rgba(126,226,184,.06);">
<div style="border:2px dashed #cdcdcd; border-radius:16px; height:220px; display:grid; place-items:center; text-align:center; background:#f5f5f5; cursor:pointer; transition:border-color .12s ease, background .12s ease;" style-hover="border-color:#e60023; background:#fafafa;">
<div>
<div style="font-size:15px; font-weight:600;">Dateien hierher ziehen</div>
<div style="font-size:13px; color:rgba(242,239,233,.55); margin-top:6px;">MP4, MOV oder bis zu 10 Bilder</div>
<div style="font-size:16px; font-weight:700;">Dateien hierher ziehen</div>
<div style="font-size:14px; color:#767676; margin-top:6px;">MP4, MOV oder bis zu 10 Bilder</div>
</div>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
<label style="display:flex; flex-direction:column; gap:8px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:rgba(242,239,233,.5);">Titel
<input value="{{ draftTitle }}" onChange="{{ setTitle }}" style="padding:13px 15px; border-radius:11px; border:1px solid rgba(255,255,255,.16); background:rgba(0,0,0,.24); color:#f2efe9; font-family:inherit; font-size:15px; letter-spacing:normal; text-transform:none;">
<label style="display:flex; flex-direction:column; gap:8px; font-size:13px; font-weight:600; color:#111111;">Titel
<input value="{{ draftTitle }}" onChange="{{ setTitle }}" style="padding:14px 16px; border-radius:16px; border:2px solid #cdcdcd; background:#ffffff; color:#111111; font-family:inherit; font-size:15px; font-weight:400;">
</label>
<label style="display:flex; flex-direction:column; gap:8px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:rgba(242,239,233,.5);">Untertitel
<input value="{{ draftSub }}" onChange="{{ setSub }}" style="padding:13px 15px; border-radius:11px; border:1px solid rgba(255,255,255,.16); background:rgba(0,0,0,.24); color:#f2efe9; font-family:inherit; font-size:15px; letter-spacing:normal; text-transform:none;">
<label style="display:flex; flex-direction:column; gap:8px; font-size:13px; font-weight:600; color:#111111;">Untertitel
<input value="{{ draftSub }}" onChange="{{ setSub }}" style="padding:14px 16px; border-radius:16px; border:2px solid #cdcdcd; background:#ffffff; color:#111111; font-family:inherit; font-size:15px; font-weight:400;">
</label>
<div style="display:flex; flex-direction:column; gap:10px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; color:rgba(242,239,233,.5);">Art
<div style="display:flex; flex-direction:column; gap:10px; font-size:13px; font-weight:600; color:#111111;">Art
<div style="display:flex; gap:8px;">
<sc-for list="{{ kindOptions }}" as="k" hint-placeholder-count="3">
<button onClick="{{ k.go }}" style="{{ k.style }}" style-hover="border-color:rgba(126,226,184,.6);">{{ k.label }}</button>
<button onClick="{{ k.go }}" style="{{ k.style }}">{{ k.label }}</button>
</sc-for>
</div>
</div>
</div>
<div style="display:flex; gap:12px;">
<button onClick="{{ publish }}" style="padding:13px 24px; border-radius:12px; border:1px solid rgba(126,226,184,.6); background:transparent; color:#8fe3c1; font-family:inherit; font-size:14px; font-weight:600; cursor:pointer; transition:background .18s ease;" style-hover="background:rgba(126,226,184,.14);">Veröffentlichen</button>
<button style="padding:13px 24px; border-radius:12px; border:1px solid rgba(255,255,255,.16); background:transparent; color:rgba(242,239,233,.8); font-family:inherit; font-size:14px; cursor:pointer; transition:background .18s ease;" style-hover="background:rgba(255,255,255,.07);">Als Entwurf sichern</button>
<div style="display:flex; gap:8px;">
<button onClick="{{ publish }}" style="padding:14px 24px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#ad081b;">Veröffentlichen</button>
<button style="padding:14px 24px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#e2e2e2;">Als Entwurf sichern</button>
</div>
</div>
<div>
<div style="font-size:11px; letter-spacing:.16em; text-transform:uppercase; color:rgba(242,239,233,.45); margin-bottom:12px;">Vorschau</div>
<div style="position:relative; height:420px; border-radius:22px; overflow:hidden; border:1px solid rgba(255,255,255,.12); background:linear-gradient(155deg,#1f8163 0%,#0d4034 100%);">
<div style="font-size:12px; font-weight:600; color:#767676; margin-bottom:12px;">Vorschau</div>
<div style="position:relative; height:420px; border-radius:16px; overflow:hidden; background:linear-gradient(155deg,#1f8163 0%,#0d4034 100%);">
<div style="position:absolute; inset:0; background:repeating-linear-gradient(48deg, rgba(255,255,255,.07) 0 2px, transparent 2px 15px); opacity:.5;"></div>
<div style="position:absolute; inset:auto 0 0 0; padding:18px; background:linear-gradient(0deg, rgba(0,0,0,.72), transparent);">
<div style="position:absolute; inset:auto 0 0 0; padding:18px; background:linear-gradient(0deg, rgba(0,0,0,.68), transparent); color:#ffffff;">
<div style="font-size:18px; font-weight:700;">{{ draftTitle }}</div>
<div style="font-size:13px; color:rgba(255,255,255,.7);">{{ draftSub }}</div>
<div style="font-size:14px; color:rgba(255,255,255,.8);">{{ draftSub }}</div>
</div>
</div>
</div>
@@ -250,15 +198,15 @@
</sc-if>
<sc-if value="{{ isSettings }}" hint-placeholder-val="{{ false }}">
<section style="flex:1; overflow:auto; padding:38px 44px; animation:riseIn .26s ease;">
<div style="max-width:660px;">
<div style="font-size:30px; font-weight:600; letter-spacing:-.02em;">Einstellungen</div>
<div style="margin-top:26px; border:1px solid rgba(255,255,255,.1); border-radius:16px; overflow:hidden;">
<section style="flex:1; overflow:auto; padding:32px 24px 40px; animation:riseIn .26s ease;">
<div style="max-width:660px; margin:0 auto;">
<div style="font-size:30px; font-weight:700; letter-spacing:-.02em;">Einstellungen</div>
<div style="margin-top:24px; border-radius:16px; overflow:hidden; box-shadow:0 1px 20px rgba(0,0,0,.12);">
<sc-for list="{{ settings }}" as="s" hint-placeholder-count="4">
<div style="display:flex; align-items:center; justify-content:space-between; gap:20px; padding:18px 20px; border-bottom:1px solid rgba(255,255,255,.07); background:rgba(255,255,255,.02);">
<div style="display:flex; align-items:center; justify-content:space-between; gap:20px; padding:20px; border-bottom:1px solid #efefef; background:#ffffff;">
<div>
<div style="font-size:15px; font-weight:600;">{{ s.label }}</div>
<div style="font-size:13px; color:rgba(242,239,233,.55); margin-top:3px; text-wrap:pretty;">{{ s.hint }}</div>
<div style="font-size:16px; font-weight:700;">{{ s.label }}</div>
<div style="font-size:14px; color:#767676; margin-top:3px; text-wrap:pretty;">{{ s.hint }}</div>
</div>
<button onClick="{{ s.toggle }}" style="{{ s.track }}">
<span style="{{ s.knob }}"></span>
@@ -270,232 +218,326 @@
</section>
</sc-if>
<sc-if value="{{ selectionOpen }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed; left:50%; bottom:28px; transform:translateX(-50%); display:flex; align-items:center; gap:12px; padding:12px 12px 12px 22px; border-radius:32px; background:#ffffff; box-shadow:0 1px 20px rgba(0,0,0,.22); z-index:45; animation:riseIn .2s ease;">
<div style="font-size:15px; font-weight:700;">{{ selectionCount }}</div>
<button onClick="{{ saveSelection }}" style="padding:12px 20px; border:none; border-radius:24px; background:#e60023; color:#ffffff; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#ad081b;">Zur Sammlung hinzufügen</button>
<button onClick="{{ clearSelection }}" style="padding:12px 20px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer; transition:background .12s ease;" style-hover="background:#e2e2e2;">Auswahl aufheben</button>
</div>
</sc-if>
<sc-if value="{{ toast }}" hint-placeholder-val="{{ false }}">
<div style="position:fixed; left:50%; bottom:34px; transform:translateX(-50%); padding:12px 22px; border-radius:999px; border:1px solid rgba(255,255,255,.16); background:rgba(10,24,20,.92); backdrop-filter:blur(10px); font-size:14px; animation:riseIn .2s ease; z-index:40;">{{ toast }}</div>
<div style="{{ toastStyle }}">{{ toast }}</div>
</sc-if>
</main>
<sc-if value="{{ detailOpen }}" hint-placeholder-val="{{ false }}">
<div onClick="{{ closeDetail }}" style="position:fixed; inset:0; background:rgba(0,0,0,.6); display:grid; place-items:center; padding:32px; z-index:60; animation:fadeIn .16s ease;">
<div onClick="{{ stop }}" style="position:relative; width:min(1016px,100%); max-height:calc(100vh - 64px); background:#ffffff; border-radius:32px; overflow:hidden; display:grid; grid-template-columns:1fr 1fr; animation:riseIn .2s ease;">
<button onClick="{{ closeDetail }}" title="Schließen" style="position:absolute; left:16px; top:16px; width:40px; height:40px; border:none; border-radius:50%; background:#ffffff; box-shadow:0 1px 8px rgba(0,0,0,.2); color:#111111; font-size:16px; cursor:pointer; z-index:2;" style-hover="background:#e9e9e9;"></button>
<div style="{{ detailMedia }}">
<div style="position:absolute; inset:0; background:{{ detailStripes }}; opacity:.45;"></div>
</div>
<div style="padding:32px; overflow:auto; display:flex; flex-direction:column; gap:20px;">
<div style="display:flex; align-items:center; justify-content:flex-end; gap:8px;">
<button onClick="{{ detailHide }}" style="height:48px; padding:0 18px; border:none; border-radius:24px; background:#efefef; color:#111111; font-family:inherit; font-size:15px; font-weight:700; cursor:pointer;" style-hover="background:#e2e2e2;">Nicht mehr zeigen</button>
<button onClick="{{ detailToggleSave }}" style="{{ detailSaveStyle }}">{{ detailSaveLabel }}</button>
</div>
<div>
<div style="font-size:32px; font-weight:700; letter-spacing:-.02em; text-wrap:pretty;">{{ detailTitle }}</div>
<div style="display:flex; align-items:center; gap:12px; margin-top:16px;">
<div style="width:48px; height:48px; border-radius:50%; flex:none; background:{{ detailAvatar }};"></div>
<div style="min-width:0;">
<div style="font-size:16px; font-weight:700;">{{ detailName }}</div>
<div style="font-size:14px; color:#767676;">{{ detailSub }}</div>
</div>
</div>
</div>
<p style="margin:0; font-size:16px; line-height:1.6; color:#333333; text-wrap:pretty;">{{ detailBody }}</p>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:10px;">
<sc-for list="{{ detailStats }}" as="s" hint-placeholder-count="3">
<div style="background:#f5f5f5; border-radius:16px; padding:14px;">
<div style="font-size:18px; font-weight:700; font-variant-numeric:tabular-nums;">{{ s.value }}</div>
<div style="font-size:12px; color:#767676; margin-top:3px;">{{ s.label }}</div>
</div>
</sc-for>
</div>
<div style="display:flex; flex-wrap:wrap; gap:8px;">
<sc-for list="{{ detailTags }}" as="tag" hint-placeholder-count="4">
<span style="padding:8px 14px; border-radius:18px; background:#efefef; font-size:13px; font-weight:600; color:#111111;">{{ tag }}</span>
</sc-for>
</div>
</div>
</div>
</div>
</sc-if>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;stackDepth&quot;:{&quot;editor&quot;:&quot;int&quot;,&quot;default&quot;:3,&quot;min&quot;:1,&quot;max&quot;:5,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;Feed&quot;}}">
<script type="text/x-dc" data-dc-script data-props="{&quot;columns&quot;:{&quot;editor&quot;:&quot;int&quot;,&quot;default&quot;:5,&quot;min&quot;:3,&quot;max&quot;:6,&quot;tsType&quot;:&quot;number&quot;,&quot;section&quot;:&quot;Raster&quot;}}">
const DATA = {
posts: [
{ name:'Mara S.', sub:'Modell · Glow Serum v3', kind:'Produkt', assets:'3 Bilder', caption:'Toner im Bogenfenster', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Drei Aufnahmen im Gegenlicht, aufgenommen im Studio Nord. Freigabe für Paid Social liegt vor.', tags:['Beauty','Studio','Hochformat','Freigegeben'], stats:[['3','Assets'],['12s','Länge'],['94','Score']] },
{ name:'Tobias R.', sub:'Creator · Kaltbrüh-Serie', kind:'Video', assets:'1 Clip', caption:'Erste Kanne am Morgen', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vertikaler Clip mit Naturton, geschnitten auf zwölf Sekunden. Untertitel bereits eingebrannt.', tags:['Food','Handheld','Untertitel'], stats:[['1','Asset'],['12s','Länge'],['88','Score']] },
{ name:'Leonie B.', sub:'Modell · Leinen SS26', kind:'Produkt', assets:'5 Bilder', caption:'Leinen gegen die Mittagssonne', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Fünf Stills aus der Sommerstrecke, alle im gleichen Weißabgleich. Retusche steht noch aus.', tags:['Fashion','Außen','Serie'], stats:[['5','Assets'],['—','Länge'],['76','Score']] },
{ name:'Nils P.', sub:'Creator · Werkstattnotiz', kind:'Video', assets:'2 Clips', caption:'Hobel, zweiter Durchgang', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Zwei Sequenzen aus der Werkstatt, Ton direkt aus der Kamera. Eignet sich als Hintergrundmaterial.', tags:['Handwerk','Ton','Roh'], stats:[['2','Assets'],['26s','Länge'],['81','Score']] },
{ name:'Anke V.', sub:'Modell · Keramik-Drop', kind:'Produkt', assets:'4 Bilder', caption:'Glasur, noch feucht', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Nahaufnahmen der neuen Glasur auf Naturpapier. Für den Katalog abgenommen.', tags:['Keramik','Makro','Katalog'], stats:[['4','Assets'],['—','Länge'],['90','Score']] }
{ id:'p1', name:'Mara S.', sub:'Modell · Glow Serum v3', kind:'Produkt', assets:'3 Bilder', caption:'Toner im Bogenfenster', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Drei Aufnahmen im Gegenlicht, aufgenommen im Studio Nord. Freigabe für Paid Social liegt vor.', tags:['Beauty','Studio','Hochformat','Freigegeben'], stats:[['3','Assets'],['12s','Länge'],['94','Score']] },
{ id:'p2', name:'Tobias R.', sub:'Creator · Kaltbrüh-Serie', kind:'Video', assets:'1 Clip', caption:'Erste Kanne am Morgen', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vertikaler Clip mit Naturton, geschnitten auf zwölf Sekunden. Untertitel bereits eingebrannt.', tags:['Food','Handheld','Untertitel'], stats:[['1','Asset'],['12s','Länge'],['88','Score']] },
{ id:'p3', name:'Leonie B.', sub:'Modell · Leinen SS26', kind:'Produkt', assets:'5 Bilder', caption:'Leinen gegen die Mittagssonne', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Fünf Stills aus der Sommerstrecke, alle im gleichen Weißabgleich. Retusche steht noch aus.', tags:['Fashion','Außen','Serie'], stats:[['5','Assets'],['—','Länge'],['76','Score']] },
{ id:'p4', name:'Nils P.', sub:'Creator · Werkstattnotiz', kind:'Video', assets:'2 Clips', caption:'Hobel, zweiter Durchgang', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Zwei Sequenzen aus der Werkstatt, Ton direkt aus der Kamera. Eignet sich als Hintergrundmaterial.', tags:['Handwerk','Ton','Roh'], stats:[['2','Assets'],['26s','Länge'],['81','Score']] },
{ id:'p5', name:'Anke V.', sub:'Modell · Keramik-Drop', kind:'Produkt', assets:'4 Bilder', caption:'Glasur, noch feucht', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Nahaufnahmen der neuen Glasur auf Naturpapier. Für den Katalog abgenommen.', tags:['Keramik','Makro','Katalog'], stats:[['4','Assets'],['—','Länge'],['90','Score']] },
{ id:'p6', name:'Jorin M.', sub:'Creator · Leuchtenserie', kind:'Produkt', assets:'6 Bilder', caption:'Messing bei Abendlicht', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Sechs Aufnahmen derselben Leuchte über zwei Stunden hinweg. Die letzten drei sind die stärksten.', tags:['Interior','Abendlicht','Serie'], stats:[['6','Assets'],['—','Länge'],['85','Score']] },
{ id:'p7', name:'Selma D.', sub:'Modell · Denim-Kapsel', kind:'Produkt', assets:'2 Bilder', caption:'Naht, doppelt gesteppt', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Zwei Detailaufnahmen für die Produktseite. Farbe ist kalibriert, kein Nachschärfen nötig.', tags:['Detail','Produktseite','Kalibriert'], stats:[['2','Assets'],['—','Länge'],['79','Score']] },
{ id:'p8', name:'Ravi K.', sub:'Creator · Marktbesuch', kind:'Video', assets:'3 Clips', caption:'Kisten werden ausgeladen', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Handkamera am frühen Morgen, viel Umgebungsgeräusch. Schnitt steht noch aus.', tags:['Reportage','Handheld','Roh'], stats:[['3','Assets'],['38s','Länge'],['72','Score']] },
{ id:'p9', name:'Nora W.', sub:'Modell · Seifen-Relaunch', kind:'Produkt', assets:'4 Bilder', caption:'Schaum auf Steingut', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Vier Stills mit weichem Seitenlicht. Für Social freigegeben, Print noch offen.', tags:['Beauty','Studio','Teilfreigabe'], stats:[['4','Assets'],['—','Länge'],['87','Score']] },
{ id:'p10', name:'Elias T.', sub:'Creator · Nachtfahrt', kind:'Video', assets:'1 Clip', caption:'Scheinwerfer auf nasser Straße', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Ein Take aus dem fahrenden Wagen, sehr kontrastreich. Braucht eine ruhige Tonspur.', tags:['Nacht','Bewegung','Kontrast'], stats:[['1','Asset'],['22s','Länge'],['83','Score']] }
],
videos: [
{ name:'Studio Nord', sub:'Reel · Behind the scenes', kind:'Video', assets:'1 Clip', caption:'Aufbau um sechs Uhr', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Ein durchgehender Take vom Aufbau bis zum ersten Licht.', tags:['BTS','Longtake'], stats:[['1','Asset'],['48s','Länge'],['83','Score']] },
{ name:'Yara M.', sub:'Creator · Tanzstudie', kind:'Video', assets:'3 Clips', caption:'Drehung, dritter Versuch', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Varianten derselben Bewegung, Slow Motion in der Mitte.', tags:['Bewegung','Slowmo'], stats:[['3','Assets'],['31s','Länge'],['87','Score']] },
{ name:'Felix H.', sub:'Creator · Küchennotiz', kind:'Video', assets:'1 Clip', caption:'Teig ruht dreißig Minuten', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Ruhiger Ablauf ohne Schnitt, gut für Voiceover.', tags:['Food','Ruhig'], stats:[['1','Asset'],['19s','Länge'],['79','Score']] }
{ id:'v1', name:'Studio Nord', sub:'Reel · Behind the scenes', kind:'Video', assets:'1 Clip', caption:'Aufbau um sechs Uhr', hue:[ '#1c6f7f', '#0a2c33' ], avatar:'linear-gradient(140deg,#2f8fa0,#0d3b44)', body:'Ein durchgehender Take vom Aufbau bis zum ersten Licht.', tags:['BTS','Longtake'], stats:[['1','Asset'],['48s','Länge'],['83','Score']] },
{ id:'v2', name:'Yara M.', sub:'Creator · Tanzstudie', kind:'Video', assets:'3 Clips', caption:'Drehung, dritter Versuch', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Varianten derselben Bewegung, Slow Motion in der Mitte.', tags:['Bewegung','Slowmo'], stats:[['3','Assets'],['31s','Länge'],['87','Score']] },
{ id:'v3', name:'Felix H.', sub:'Creator · Küchennotiz', kind:'Video', assets:'1 Clip', caption:'Teig ruht dreißig Minuten', hue:[ '#7a5a2a', '#2f2110' ], avatar:'linear-gradient(140deg,#b08434,#513c14)', body:'Ruhiger Ablauf ohne Schnitt, gut für Voiceover.', tags:['Food','Ruhig'], stats:[['1','Asset'],['19s','Länge'],['79','Score']] },
{ id:'v4', name:'Studio Nord', sub:'Reel · Lichtsetzung', kind:'Video', assets:'2 Clips', caption:'Softbox wandert nach links', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Zwei kurze Sequenzen, die den Aufbau erklären. Als Erklärstück brauchbar.', tags:['BTS','Erklärung'], stats:[['2','Assets'],['27s','Länge'],['80','Score']] },
{ id:'v5', name:'Mira L.', sub:'Creator · Werkbank', kind:'Video', assets:'4 Clips', caption:'Späne fallen langsam', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Vier Makroeinstellungen in Zeitlupe. Sehr ruhig, funktioniert stumm.', tags:['Makro','Slowmo','Stumm'], stats:[['4','Assets'],['52s','Länge'],['91','Score']] },
{ id:'v6', name:'Tomas B.', sub:'Creator · Uferweg', kind:'Video', assets:'1 Clip', caption:'Gegenlicht am Wasser', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#2fa87f,#0e5442)', body:'Ein langer Schwenk gegen die tief stehende Sonne. Farbstich bewusst gelassen.', tags:['Außen','Gegenlicht'], stats:[['1','Asset'],['41s','Länge'],['77','Score']] },
{ id:'v7', name:'Ida F.', sub:'Reel · Verpackung', kind:'Video', assets:'2 Clips', caption:'Karton wird gefaltet', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Zwei Durchgänge desselben Handgriffs, der zweite ist sauberer.', tags:['Unboxing','Hände'], stats:[['2','Assets'],['16s','Länge'],['84','Score']] },
{ id:'v8', name:'Kaan Ö.', sub:'Creator · Dachterrasse', kind:'Video', assets:'3 Clips', caption:'Wind in der Markise', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Drei Einstellungen bei starkem Wind, Ton unbrauchbar, Bild sehr gut.', tags:['Außen','Ton ersetzen'], stats:[['3','Assets'],['34s','Länge'],['75','Score']] }
],
following: [
{ name:'Mara S.', sub:'Folge ich · neuer Post', kind:'Produkt', assets:'2 Bilder', caption:'Nachschub aus dem Studio', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Zwei zusätzliche Aufnahmen aus derselben Strecke.', tags:['Beauty','Nachtrag'], stats:[['2','Assets'],['—','Länge'],['92','Score']] },
{ name:'Tobias R.', sub:'Folge ich · Serie läuft', kind:'Video', assets:'4 Clips', caption:'Kaltbrüh, Folge vier', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vierte Folge der Reihe, gleiche Bildsprache wie zuvor.', tags:['Serie','Food'], stats:[['4','Assets'],['44s','Länge'],['85','Score']] }
{ id:'f1', name:'Mara S.', sub:'Folge ich · neuer Post', kind:'Produkt', assets:'2 Bilder', caption:'Nachschub aus dem Studio', hue:[ '#1f8163', '#0d4034' ], avatar:'linear-gradient(140deg,#b2323c,#5d1620)', body:'Zwei zusätzliche Aufnahmen aus derselben Strecke.', tags:['Beauty','Nachtrag'], stats:[['2','Assets'],['—','Länge'],['92','Score']] },
{ id:'f2', name:'Tobias R.', sub:'Folge ich · Serie läuft', kind:'Video', assets:'4 Clips', caption:'Kaltbrüh, Folge vier', hue:[ '#256f8b', '#0c2f3f' ], avatar:'linear-gradient(140deg,#2f7f9e,#10394a)', body:'Vierte Folge der Reihe, gleiche Bildsprache wie zuvor.', tags:['Serie','Food'], stats:[['4','Assets'],['44s','Länge'],['85','Score']] },
{ id:'f3', name:'Leonie B.', sub:'Folge ich · Vorschau', kind:'Produkt', assets:'3 Bilder', caption:'Leinen, zweite Farbe', hue:[ '#8a6a2f', '#3a2a11' ], avatar:'linear-gradient(140deg,#c99a4a,#6a4a17)', body:'Drei Aufnahmen der zweiten Farbvariante, noch nicht freigegeben.', tags:['Fashion','Vorschau'], stats:[['3','Assets'],['—','Länge'],['78','Score']] },
{ id:'f4', name:'Anke V.', sub:'Folge ich · Werkstatt', kind:'Produkt', assets:'5 Bilder', caption:'Zweiter Brand im Ofen', hue:[ '#2f6f5a', '#0e332b' ], avatar:'linear-gradient(140deg,#3f9a7c,#124334)', body:'Fünf Stills vom zweiten Brand, Farben deutlich satter als beim ersten.', tags:['Keramik','Serie'], stats:[['5','Assets'],['—','Länge'],['89','Score']] },
{ id:'f5', name:'Yara M.', sub:'Folge ich · Probe', kind:'Video', assets:'2 Clips', caption:'Aufwärmen im Spiegelsaal', hue:[ '#6a2f7a', '#26102c' ], avatar:'linear-gradient(140deg,#8b46a0,#341340)', body:'Zwei ruhige Einstellungen vor der eigentlichen Aufnahme.', tags:['Bewegung','Probe'], stats:[['2','Assets'],['29s','Länge'],['81','Score']] },
{ id:'f6', name:'Nils P.', sub:'Folge ich · Nachtrag', kind:'Video', assets:'1 Clip', caption:'Letzter Schliff', hue:[ '#7a2f38', '#2c1014' ], avatar:'linear-gradient(140deg,#a63a44,#4d151c)', body:'Ein kurzer Nachtrag zur Werkstattreihe, sauber belichtet.', tags:['Handwerk','Nachtrag'], stats:[['1','Asset'],['14s','Länge'],['86','Score']] }
]
};
const ALL = DATA.posts.concat(DATA.videos, DATA.following);
const TILE_HEIGHTS = [320, 400, 260, 340, 280, 440, 300, 360, 240, 380];
class Component extends DCLogic {
state = {
screen: 'feed', tab: 'posts', idx: 0,
drag: null, exiting: null, showInfo: false, toast: '',
seen: 0, saved: 24, history: [],
screen: 'feed', tab: 'posts',
saved: ['p5'], hidden: [],
selectMode: false, selection: [],
detail: null, menuFor: null, toast: '',
draftTitle: 'Toner im Bogenfenster', draftSub: 'Modell · Glow Serum v3', draftKind: 'Produkt',
settings: { autoplay: true, sound: false, digest: true, motion: true }
};
componentDidMount() {
this._key = (e) => {
if (this.state.screen !== 'feed') return;
if (e.key === 'ArrowLeft') { e.preventDefault(); this.commit('left'); }
if (e.key === 'ArrowRight') { e.preventDefault(); this.commit('right'); }
if (e.key === 'ArrowUp') { e.preventDefault(); this.undo(); }
if (e.key !== 'Escape') return;
if (this.state.detail) { e.preventDefault(); this.setState({ detail: null }); return; }
if (this.state.menuFor) { e.preventDefault(); this.setState({ menuFor: null }); return; }
if (this.state.selectMode) { e.preventDefault(); this.setState({ selectMode: false, selection: [] }); }
};
window.addEventListener('keydown', this._key);
}
componentWillUnmount() { window.removeEventListener('keydown', this._key); if (this._t) clearTimeout(this._t); }
componentWillUnmount() { window.removeEventListener('keydown', this._key); if (this._toast) clearTimeout(this._toast); }
deck() { const t = this.state.tab; return t === 'videos' ? DATA.videos : t === 'following' ? DATA.following : DATA.posts; }
visible() { return this.deck().filter(c => !this.state.hidden.includes(c.id)); }
byId(id) { return ALL.find(c => c.id === id); }
flash(msg) { this.setState({ toast: msg }); clearTimeout(this._toast); this._toast = setTimeout(() => this.setState({ toast: '' }), 1700); }
commit(dir) {
if (this.state.exiting) return;
if (this.state.idx >= this.deck().length) return;
const card = this.deck()[this.state.idx];
this.setState({ exiting: dir, drag: null });
this._t = setTimeout(() => this.setState(s => ({
exiting: null, idx: s.idx + 1, drag: null,
seen: s.seen + 1, saved: dir === 'right' ? s.saved + 1 : s.saved,
history: s.history.concat([{ idx: s.idx, dir }])
})), 320);
this.flash(dir === 'right' ? 'Zu „' + card.name + '" gemerkt' : 'Übersprungen');
toggleSave(id) {
const card = this.byId(id);
const wasSaved = this.state.saved.includes(id);
this.setState(s => ({ saved: wasSaved ? s.saved.filter(x => x !== id) : s.saved.concat([id]) }));
this.flash(wasSaved ? 'Aus der Sammlung entfernt' : 'Zu „' + card.name + '" gemerkt');
}
undo() {
const h = this.state.history;
if (!h.length) { this.flash('Nichts zurückzunehmen'); return; }
const last = h[h.length - 1];
this.setState(s => ({ idx: last.idx, history: s.history.slice(0, -1), saved: last.dir === 'right' ? Math.max(0, s.saved - 1) : s.saved, seen: Math.max(0, s.seen - 1), exiting: null, drag: null }));
hide(id) {
this.setState(s => ({ hidden: s.hidden.concat([id]), menuFor: null, detail: s.detail === id ? null : s.detail,
selection: s.selection.filter(x => x !== id) }));
this.flash('Wird nicht mehr gezeigt');
}
onDown = (e) => {
if (this.state.exiting) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
this._start = { x: e.clientX, y: e.clientY };
this.setState({ drag: { x: 0, y: 0, active: true } });
};
onMove = (e) => {
if (!this.state.drag || !this.state.drag.active) return;
this.setState({ drag: { x: e.clientX - this._start.x, y: e.clientY - this._start.y, active: true } });
};
onUp = () => {
const d = this.state.drag;
if (!d) return;
if (d.x > 120) this.commit('right');
else if (d.x < -120) this.commit('left');
else this.setState({ drag: null });
};
toggleSelect(id) {
this.setState(s => ({ selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : s.selection.concat([id]) }));
}
saveSelection() {
const n = this.state.selection.length;
this.setState(s => ({ saved: s.saved.concat(s.selection.filter(id => !s.saved.includes(id))), selection: [], selectMode: false }));
this.flash(n + (n === 1 ? ' Karte' : ' Karten') + ' zur Sammlung hinzugefügt');
}
stripes(i) { return 'repeating-linear-gradient(' + (40 + i * 9) + 'deg, rgba(255,255,255,.08) 0 2px, transparent 2px 16px)'; }
renderVals() {
const s = this.state, deck = this.deck(), depth = Math.max(1, this.props.stackDepth ?? 3);
const current = deck[s.idx];
const dx = s.drag ? s.drag.x : 0, dy = s.drag ? s.drag.y : 0;
const rot = dx / 22;
const stack = [];
for (let k = 0; k < depth; k++) {
const card = deck[s.idx + k];
if (!card) break;
const top = k === 0;
let transform = 'translate3d(0,' + (k * -12) + 'px,0) scale(' + (1 - k * 0.045) + ')';
let transition = 'transform .34s cubic-bezier(.22,.9,.3,1), opacity .3s ease';
let opacity = 1;
if (top) {
if (s.exiting) {
transform = 'translate3d(' + (s.exiting === 'right' ? 620 : -620) + 'px, -40px, 0) rotate(' + (s.exiting === 'right' ? 22 : -22) + 'deg)';
opacity = 0;
} else if (s.drag) {
transform = 'translate3d(' + dx + 'px,' + dy + 'px,0) rotate(' + rot + 'deg)';
transition = s.drag.active ? 'none' : transition;
}
}
const shell = {
position: 'absolute', inset: 0, zIndex: 10 - k, opacity, transform, transition,
borderRadius: '26px', overflow: 'hidden', boxShadow: '0 26px 60px rgba(0,0,0,.45)',
border: '1px solid rgba(255,255,255,.14)', touchAction: 'none',
cursor: top ? (s.drag && s.drag.active ? 'grabbing' : 'grab') : 'default',
pointerEvents: top ? 'auto' : 'none', userSelect: 'none'
mediaStyle(card, h, selected) {
return {
position: 'relative', height: h + 'px', borderRadius: '16px', overflow: 'hidden',
cursor: this.state.selectMode ? 'pointer' : 'zoom-in',
background: 'linear-gradient(155deg,' + card.hue[0] + ' 0%,' + card.hue[1] + ' 100%)',
outline: selected ? '4px solid #e60023' : 'none', outlineOffset: '-4px',
transition: 'outline-color .12s ease'
};
const showBadge = top && (Math.abs(dx) > 40 || !!s.exiting);
const liked = (s.exiting === 'right') || dx > 40;
stack.push({
shell: { ...shell },
down: top ? this.onDown : undefined,
move: top ? this.onMove : undefined,
up: top ? this.onUp : undefined,
media: { position: 'absolute', inset: 0, background: 'linear-gradient(155deg,' + card.hue[0] + ' 0%,' + card.hue[1] + ' 100%)' },
stripes: this.stripes(s.idx + k),
kind: card.kind, assets: card.assets, caption: card.caption, avatar: card.avatar,
name: card.name, sub: card.sub,
badgeText: liked ? 'GEMERKT' : 'SKIP',
badge: {
position: 'absolute', top: '86px', left: liked ? '24px' : 'auto', right: liked ? 'auto' : '24px',
padding: '8px 16px', borderRadius: '10px', fontWeight: 800, fontSize: '15px', letterSpacing: '.12em',
border: '2px solid ' + (liked ? 'rgba(126,226,184,.9)' : 'rgba(232,120,120,.9)'),
color: liked ? '#a9f0d0' : '#f0a8a8',
transform: 'rotate(' + (liked ? -10 : 10) + 'deg)',
opacity: showBadge ? Math.min(1, Math.abs(dx) / 110 || 1) : 0,
transition: 'opacity .16s ease', pointerEvents: 'none'
}
});
}
const navDef = [['feed','Feed','◧'],['profile','Profil','◍'],['create','Erstellen',''],['settings','Einstellungen','⚙']];
const navItems = navDef.map(([id, label, glyph]) => ({
label, glyph, go: () => this.setState({ screen: id }),
style: {
display: 'flex', alignItems: 'center', gap: '11px', padding: '11px 13px', borderRadius: '11px',
border: '1px solid ' + (s.screen === id ? 'rgba(126,226,184,.4)' : 'transparent'),
background: s.screen === id ? 'rgba(126,226,184,.12)' : 'transparent',
color: s.screen === id ? '#d6fbec' : 'rgba(242,239,233,.7)',
font: '500 14px/1 Manrope, system-ui, sans-serif', cursor: 'pointer',
transition: 'background .18s ease, color .18s ease', textAlign: 'left'
saveButton(isSaved) {
return {
padding: '11px 16px', border: 'none', borderRadius: '22px',
background: isSaved ? '#111111' : '#e60023', color: '#ffffff',
font: '700 15px/1 Manrope, system-ui, sans-serif', cursor: 'pointer',
transition: 'background .12s ease'
};
}
renderVals() {
const s = this.state;
const cols = Math.max(3, Math.min(6, this.props.columns ?? 5));
const gridStyle = { columns: String(cols), columnGap: '16px' };
const visible = this.visible();
const grid = visible.map((card, i) => {
const isSaved = s.saved.includes(card.id);
const selected = s.selection.includes(card.id);
return {
caption: card.caption, name: card.name, kind: card.kind,
stripes: this.stripes(i),
media: this.mediaStyle(card, TILE_HEIGHTS[i % TILE_HEIGHTS.length], selected),
activate: () => s.selectMode ? this.toggleSelect(card.id) : this.setState({ detail: card.id, menuFor: null }),
open: (e) => { e.stopPropagation(); this.setState({ detail: card.id, menuFor: null }); },
toggleSave: (e) => { e.stopPropagation(); this.toggleSave(card.id); },
openMenu: (e) => { e.stopPropagation(); this.setState(st => ({ menuFor: st.menuFor === card.id ? null : card.id })); },
hide: (e) => { e.stopPropagation(); this.hide(card.id); },
saveLabel: isSaved ? 'Gemerkt' : 'Merken',
saveStyle: this.saveButton(isSaved),
menuOpen: s.menuFor === card.id,
// Im Auswahlmodus verschwinden Merken und ⋯ — dort zaehlt nur die Auswahl.
saveSlot: s.selectMode
? { display: 'none' }
: { position: 'absolute', right: '12px', top: '12px', zIndex: 10 },
menuSlot: s.selectMode
? { display: 'none' }
: { position: 'absolute', right: '12px', bottom: '12px', zIndex: 10 },
menuBtn: {
width: '40px', height: '40px', border: 'none', borderRadius: '50%',
background: '#ffffff', boxShadow: '0 1px 8px rgba(0,0,0,.2)', color: '#111111',
fontSize: '18px', lineHeight: '1', cursor: 'pointer'
},
checkbox: s.selectMode
? { position: 'absolute', left: '12px', top: '12px', width: '28px', height: '28px',
borderRadius: '50%', display: 'grid', placeItems: 'center', fontSize: '15px', fontWeight: 700,
border: '2px solid #ffffff', color: selected ? '#ffffff' : 'transparent',
background: selected ? '#e60023' : 'rgba(0,0,0,.35)', pointerEvents: 'none' }
: { display: 'none' }
};
});
const railStyle = {
width: '48px', height: '48px', display: 'grid', placeItems: 'center', border: 'none',
borderRadius: '50%', cursor: 'pointer', fontSize: '20px',
transition: 'background .12s ease, color .12s ease'
};
const navDef = [['feed','Feed','⌂'],['profile','Profil','◍'],['create','Erstellen',''],['settings','Einstellungen','⚙']];
const navAll = navDef.map(([id, label, glyph]) => ({
label, glyph, go: () => this.setState({ screen: id, menuFor: null }),
cls: s.screen === id ? 'rail active' : 'rail', style: railStyle
}));
const tabDef = [['videos','Videos'],['following','Folge ich'],['posts','Posts']];
const tabs = tabDef.map(([id, label]) => ({
label, go: () => this.setState({ tab: id, idx: 0, history: [], drag: null, exiting: null, screen: 'feed' }),
label, go: () => this.setState({ tab: id, screen: 'feed', selection: [], menuFor: null, detail: null }),
style: {
padding: '9px 18px', borderRadius: '999px', border: '1px solid ' + (s.tab === id ? 'rgba(255,255,255,.22)' : 'transparent'),
background: s.tab === id ? 'rgba(255,255,255,.08)' : 'transparent',
color: s.tab === id ? '#ffffff' : 'rgba(242,239,233,.58)',
font: (s.tab === id ? '600' : '500') + ' 16px/1 Manrope, system-ui, sans-serif',
cursor: 'pointer', transition: 'background .18s ease, color .18s ease'
padding: '0 2px 12px', border: 'none', background: 'transparent',
borderBottom: '3px solid ' + (s.tab === id ? '#111111' : 'transparent'),
color: s.tab === id ? '#111111' : '#767676',
font: (s.tab === id ? '700' : '500') + ' 16px/1 Manrope, system-ui, sans-serif',
cursor: 'pointer', transition: 'color .12s ease, border-color .12s ease'
}
}));
const kindOptions = ['Produkt','Video','Serie'].map(k => ({
label: k, go: () => this.setState({ draftKind: k }),
style: {
padding: '10px 18px', borderRadius: '999px', cursor: 'pointer',
border: '1px solid ' + (s.draftKind === k ? 'rgba(126,226,184,.6)' : 'rgba(255,255,255,.16)'),
background: s.draftKind === k ? 'rgba(126,226,184,.12)' : 'transparent',
color: s.draftKind === k ? '#a9f0d0' : 'rgba(242,239,233,.75)',
font: '500 14px/1 Manrope, system-ui, sans-serif', letterSpacing: 'normal', textTransform: 'none',
transition: 'border-color .18s ease, background .18s ease'
padding: '11px 18px', borderRadius: '22px', cursor: 'pointer', border: 'none',
background: s.draftKind === k ? '#111111' : '#efefef',
color: s.draftKind === k ? '#ffffff' : '#111111',
font: '700 15px/1 Manrope, system-ui, sans-serif',
transition: 'background .12s ease, color .12s ease'
}
}));
const setDef = [['autoplay','Videos automatisch abspielen','Clips starten stumm, sobald die Karte oben liegt.'],
['sound','Ton beim Öffnen','Beim Anklicken einer Karte den Ton aktivieren.'],
const setDef = [['autoplay','Videos automatisch abspielen','Clips starten stumm, sobald die Kachel im Bild ist.'],
['sound','Ton beim Öffnen','Beim Öffnen der Detailansicht den Ton aktivieren.'],
['digest','Wochenrückblick','Jeden Montag eine Zusammenfassung der neuen Posts.'],
['motion','Bewegte Übergänge','Karten animiert ein- und ausblenden.']];
['motion','Bewegte Übergänge','Kacheln und Detailansicht animiert ein- und ausblenden.']];
const settings = setDef.map(([id, label, hint]) => {
const on = s.settings[id];
return {
label, hint,
toggle: () => this.setState(st => ({ settings: { ...st.settings, [id]: !st.settings[id] } })),
track: { position: 'relative', width: '52px', height: '30px', flex: 'none', borderRadius: '999px', cursor: 'pointer',
border: '1px solid ' + (on ? 'rgba(126,226,184,.6)' : 'rgba(255,255,255,.2)'),
background: on ? 'rgba(126,226,184,.18)' : 'rgba(255,255,255,.05)', transition: 'background .2s ease, border-color .2s ease' },
knob: { position: 'absolute', top: '3px', left: on ? '25px' : '3px', width: '22px', height: '22px', borderRadius: '50%',
background: on ? '#8fe3c1' : 'rgba(242,239,233,.55)', transition: 'left .2s cubic-bezier(.22,.9,.3,1), background .2s ease' }
border: 'none', background: on ? '#e60023' : '#cdcdcd', transition: 'background .2s ease' },
knob: { position: 'absolute', top: '3px', left: on ? '25px' : '3px', width: '24px', height: '24px', borderRadius: '50%',
background: '#ffffff', boxShadow: '0 1px 3px rgba(0,0,0,.25)', transition: 'left .2s cubic-bezier(.22,.9,.3,1)' }
};
});
const gallery = DATA.posts.concat(DATA.videos).map((c, i) => ({
title: c.caption, stripes: this.stripes(i),
tile: { position: 'relative', height: '230px', borderRadius: '16px', overflow: 'hidden', cursor: 'pointer',
border: '1px solid rgba(255,255,255,.12)', transition: 'transform .2s ease, border-color .2s ease',
background: 'linear-gradient(155deg,' + c.hue[0] + ' 0%,' + c.hue[1] + ' 100%)' }
const gallery = s.saved.map(id => this.byId(id)).filter(Boolean).map((c, i) => ({
caption: c.caption, stripes: this.stripes(i),
media: this.mediaStyle(c, TILE_HEIGHTS[i % TILE_HEIGHTS.length], false),
open: () => this.setState({ detail: c.id })
}));
const d = s.detail ? this.byId(s.detail) : null;
const detailSaved = d ? s.saved.includes(d.id) : false;
return {
navItems, tabs, stack, kindOptions, settings, gallery,
navTop: navAll.slice(0, 3), navBottom: navAll.slice(3), tabs, kindOptions, settings,
grid, gridStyle, gallery,
isFeed: s.screen === 'feed', isProfile: s.screen === 'profile', isCreate: s.screen === 'create', isSettings: s.screen === 'settings',
stackEmpty: stack.length === 0,
showInfo: s.showInfo, toggleInfo: () => this.setState(st => ({ showInfo: !st.showInfo })),
counter: Math.min(s.idx + 1, deck.length) + ' / ' + deck.length,
swipesToday: String(s.seen).padStart(2, '0'),
onLike: () => this.commit('right'), onNope: () => this.commit('left'), onUndo: () => this.undo(),
reset: () => this.setState({ idx: 0, history: [] }),
detailTitle: current ? current.caption : 'Keine Karte',
detailSub: current ? current.name + ' · ' + current.sub : 'Stapel leer',
detailBody: current ? current.body : 'Lade den Stapel neu, um weitere Karten zu sichten.',
detailTags: current ? current.tags : [],
detailStats: current ? current.stats.map(([value, label]) => ({ value, label })) : [],
profileStats: [{ value: '38', label: 'Posts' }, { value: String(s.saved), label: 'Gemerkt' }, { value: '1.204', label: 'Follower' }],
gridEmpty: visible.length === 0,
unhideAll: () => this.setState({ hidden: [] }),
counter: visible.length + ' Karten · ' + s.saved.length + ' gemerkt',
toggleSelectMode: () => this.setState(st => ({ selectMode: !st.selectMode, selection: [], menuFor: null })),
selectToggleLabel: s.selectMode ? 'Fertig' : 'Auswählen',
selectToggleStyle: {
padding: '10px 18px', border: 'none', borderRadius: '22px', cursor: 'pointer',
background: s.selectMode ? '#111111' : '#efefef', color: s.selectMode ? '#ffffff' : '#111111',
font: '700 15px/1 Manrope, system-ui, sans-serif', transition: 'background .12s ease, color .12s ease'
},
selectionOpen: s.selectMode && s.selection.length > 0,
selectionCount: s.selection.length + ' ausgewählt',
saveSelection: () => this.saveSelection(),
clearSelection: () => this.setState({ selection: [] }),
detailOpen: !!d,
closeDetail: () => this.setState({ detail: null }),
stop: (e) => e.stopPropagation(),
detailMedia: d ? { position: 'relative', minHeight: '520px', background: 'linear-gradient(155deg,' + d.hue[0] + ' 0%,' + d.hue[1] + ' 100%)' } : {},
detailStripes: d ? this.stripes(2) : 'none',
detailTitle: d ? d.caption : '',
detailName: d ? d.name : '',
detailSub: d ? d.sub : '',
detailAvatar: d ? d.avatar : 'none',
detailBody: d ? d.body : '',
detailTags: d ? d.tags : [],
detailStats: d ? d.stats.map(([value, label]) => ({ value, label })) : [],
detailSaveLabel: detailSaved ? 'Gemerkt' : 'Merken',
detailSaveStyle: { ...this.saveButton(detailSaved), height: '48px', padding: '0 24px', borderRadius: '24px' },
detailToggleSave: () => this.toggleSave(s.detail),
detailHide: () => this.hide(s.detail),
profileStats: [{ value: '38', label: 'Posts' }, { value: String(s.saved.length), label: 'Gemerkt' }, { value: '1.204', label: 'Follower' }],
draftTitle: s.draftTitle, draftSub: s.draftSub,
setTitle: (e) => this.setState({ draftTitle: e.target.value }),
setSub: (e) => this.setState({ draftSub: e.target.value }),
publish: () => this.flash('Post veröffentlicht'),
toast: s.toast
toast: s.toast,
toastStyle: {
position: 'fixed', left: '50%', bottom: s.selectMode && s.selection.length ? '96px' : '28px',
transform: 'translateX(-50%)', padding: '16px 22px', borderRadius: '16px',
background: '#ffffff', boxShadow: '0 1px 20px rgba(0,0,0,.22)', color: '#111111',
fontSize: '15px', fontWeight: 600, animation: 'riseIn .2s ease', zIndex: 50
}
};
}
}

263
planung/app-aufbau.md Normal file
View File

@@ -0,0 +1,263 @@
# App-Aufbau Screens, Navigation, Datenflüsse
**Stand:** 28. Juli 2026
**Zweck:** Beschreibt, wie die App aufgebaut sein muss, damit die Funktionen aus `konzept-bilder-feed.md` und `projekt-uebersicht.md` zusammenpassen. Grundlage für die Umsetzung der Etappenplan steht in `programmier-plan.md`.
**Bezug:** Prototyp `prototyp-app.html` im Repo `git.webklar.com/knso/videogen` (nur Handy-Ansicht; die Desktop-Adaption ist noch nicht gültig).
---
## 1. Getroffene Grundentscheidungen
| Thema | Entscheidung |
|---|---|
| Stack | **Expo / React Native** eine Codebase für Web und native. Zahlung über die Web-App (App-Store-Abgabe umgehen) |
| Bottom-Navigation | **drei Tabs: Feed · · Profil** |
| Social Graph | **ja** Nutzer können einander folgen, fremde Profile ansehen |
| Fremdes Profil zeigt | **nur veröffentlichte Posts** keine Modelle, keine Ordner, keine Statistiken |
| Kopieren | **eigener Kopier-Screen** mit Slot-Chips, erst danach Create |
| Konto-Modell | **ein Konto = eine Brand** (Appwrite-Team), kein Brand-Umschalter |
| Einstieg der Umsetzung | **Fundament zuerst** Schema, Auth, Navigationsgerüst |
---
## 2. Drei Prinzipien, aus denen sich fast alles ableitet
### 2.1 Generierung ist immer asynchron
Kein Button darf auf ein Ergebnis warten. Jede KI-Aktion legt eine Zeile in `jobs` an, das UI abonniert den Realtime-Kanal und rendert den Fortschritt. Das ist keine Optimierung, sondern eine Notwendigkeit: eine Bildkette sind drei bis fünf Generierungen, ein Video dauert Minuten.
**Konsequenz für den Aufbau:** Es braucht einen **globalen Job-Bereich**, der screenübergreifend sichtbar ist im Prototyp existiert er nicht. Wer während der Generierung wegnavigiert, muss das Ergebnis wiederfinden. Dafür ist der Entwürfe-Screen zuständig.
> Der Prototyp täuscht hier: dort ist „Bestätigen & 4 Videos generieren" ein direkter Screenwechsel (`prototyp-app.html:820`). Genauso „Modell generieren" (`:911`) und „Neue Versionen generieren" (`:970`). Alle drei sind in Wahrheit Wartezustände.
### 2.2 Der aktive Ordner ist globaler Zustand
Der Ordner bestimmt, welches Wissen in den Prompt wandert (`konzept-bilder-feed.md` §9). Er wird **vor** der Generierung gewählt und gilt, bis er gewechselt wird. Damit ist er kein Bildschirm-lokaler Wert, sondern App-Zustand vergleichbar mit dem aktiven Konto.
**Konsequenz:** Der aktive Ordner gehört sichtbar in die Kopfzeile des Erstellen-Bereichs, nicht in ein Untermenü. Sonst generiert der Nutzer im falschen Scope und versteht nicht, warum das Ergebnis anders aussieht als erwartet.
### 2.3 Es gibt genau eine öffentliche Grenze
Alles ist privat, außer `posts` mit `sichtbarkeit = oeffentlich` und von denen nur die Bilder, `slot_summary`, `werbetext`, `nische` und `feed_score`.
| Öffentlich | Privat |
|---|---|
| veröffentlichte Posts (Bilder, Slot-Chips, Werbetext) | `posts.prompt_sent` 🔒 |
| Anzeigename, Avatar, Nische, Followerzahl | Ordner **immer** |
| Modell-Namen als **Beschriftung** im Kopier-Screen | Modelle als nutzbare Assets, Referenzbilder, Einwilligungen |
| | `attribute_scores`, `rules`, Knowledge Base, Entwürfe, Metriken |
**Diese Grenze muss auf Datenbank-Ebene durchgesetzt werden, nicht im UI.** Appwrite-Zeilenrechte pro Team; die öffentliche Lesbarkeit wird beim Veröffentlichen als Permission auf die Zeile gesetzt. Ein UI-Filter allein ist keine Absicherung.
---
## 3. Navigationsgerüst
```
┌─ VOR DEM LOGIN ────────────────────────────────────────┐
│ Willkommen → Registrieren / Anmelden │
└────────────────────────────────────────────────────────┘
┌─ ONBOARDING (einmalig, modal, Tabs ausgeblendet) ──────┐
│ Tut 1 → 6 Pflichtfragen → Tut 2 → Uploads (optional) │
└────────────────────────────────────────────────────────┘
╔═ TABS ═════════════════════════════════════════════════╗
║ ║
║ ① FEED ② (Popover) ③ PROFIL ║
║ ├ Feed-Deck ├ Post erstellen ├ Eigenes Profil║
║ │ (Videos / ├ Video erstellen ├ Ordner ║
║ │ Folge ich / ├ Modell erstellen ├ Modelle ║
║ │ Posts) └ Entwürfe ├ Knowledge Base║
║ ├ Post-Detail ├ Einstellungen ║
║ ├ ► Kopieren └ ⚙ Dev-Modus ║
║ └ Fremdprofil (versteckt) ║
╚════════════════════════════════════════════════════════╝
```
**Warum ein Popover ist und kein Tab:** Es gibt drei verschiedene Erstellen-Abläufe (Post, Video, Modell) mit völlig unterschiedlicher Länge. Ein Tab müsste einen davon zum Standard machen. Das Popover fragt stattdessen zuerst, was entstehen soll so wie im Prototyp bereits angelegt (`#createMenu`).
**Warum die Ordner im Profil bleiben und keinen eigenen Tab bekommen:** Bei drei Tabs ist der Feed die Startseite, das Erstellen der Kern und das Profil alles, was einem selbst gehört. Ordner sind Teil davon. Damit sie trotzdem erreichbar sind, gehören sie **oben** ins Profil nicht wie im Prototyp unter Metriken und Knowledge Base.
---
## 4. Screen-Katalog
Legende Prototyp-Status: **✅ vorhanden** · **⚠️ vorhanden, unvollständig** · **🆕 fehlt**
### 4.1 Vor dem Login
| Screen | Zweck | Liest | Schreibt | Prototyp |
|---|---|---|---|---|
| **Willkommen** | Was ist die App, Registrieren/Anmelden | | | 🆕 |
| **Registrieren / Anmelden** | Appwrite Auth (E-Mail + Passwort) | | Appwrite-Account, `brands`, Team | 🆕 |
> Fehlt im Prototyp komplett. Ohne Auth gibt es keine Zeilenrechte und damit keine Mandantentrennung das ist Teil des Fundaments, nicht ein späteres Extra.
### 4.2 Onboarding (einmalig)
| Screen | Zweck | Liest | Schreibt | Prototyp |
|---|---|---|---|---|
| **Tut 1** | Erklärt, warum gefragt wird | | | ✅ `#tut1` |
| **Pflichtfragen** | 6 Fragen inkl. **Nische** | | `brands`, via P2 → `attributes` + `attribute_scores` | ⚠️ `#onboarding1` nur 5 Felder, ohne Nische, ohne `id`/`name` |
| **Tut 2** | Erklärt die Uploads | | | ✅ `#tut2` |
| **Uploads** | Logo, Produktbilder, Top-Ads, Gesicht, Kulisse | | Bucket `uploads`, via P1 → `assets`, `attributes` | ⚠️ `#onboarding2` Attrappen, kein `<input type="file">` |
**Wichtig:** Das Onboarding erscheint beim **ersten Klick auf Erstellen**, nicht beim App-Start (so im Fragenkatalog festgelegt). Der Feed ist also vorher schon benutzbar das ist gewollt, weil man erst sehen soll, was möglich ist.
### 4.3 Tab ① Feed
| Screen | Zweck | Liest | Schreibt | Prototyp |
|---|---|---|---|---|
| **Feed-Deck** | Swipe-Deck, 3 Reiter: Videos · Folge ich · Posts | `posts` (öffentlich, nach Nische gefiltert, nach `feed_score` sortiert) | `post_metrics` (views, likes) | ⚠️ `#explore` Deck-Logik da, Daten hartkodiert |
| **Post-Detail** | Bilderkette, Slot-Chips, Autor, „Nachmachen" | `posts`, `post_images`, `assets` (nur Namen) | `post_metrics` | ⚠️ `#detail` Inhalte fest verdrahtet |
| **► Kopieren** | Slots übernehmen oder ersetzen | `posts.slot_summary`, eigene `assets` (kompatibel nach `typ_tags`) | (übergibt an Create) | 🆕 |
| **Fremdprofil** | Öffentliche Posts einer Person, Folgen-Button | `brands` (öffentliche Felder), `posts`, `follows` | `follows` | 🆕 |
**Der Kopier-Screen ist der wichtigste neue Screen.** Er zeigt je Slot einen Chip mit dem Wert des Originals. Modell-Chips sind **rot markiert und pflicht zu ersetzen**; Kulisse, Licht, Kamera, Farbe sind übernehmbar. Format und Kettenlänge werden ohne Rückfrage mitgenommen. Unten: „Weiter" → Create mit vorbefüllten Slots.
**Kompatibilitätsprüfung:** Beim Antippen eines Modell-Chips werden nur eigene Modelle angeboten, deren `typ_tags` zum Original passen. Ein Fahrzeug-Slot bietet kein Lippenstift-Modell an (`konzept-bilder-feed.md` §4).
### 4.4 Tab ② Erstellen
| Screen | Zweck | Liest | Schreibt | Prototyp |
|---|---|---|---|---|
| ** Popover** | Post / Video / Modell / Entwürfe | | | ⚠️ `#createMenu` nur CSS, Markup unklar |
| **Post erstellen** | Ordner, Format, Kette, Slots, Prompt | `folders`, `assets`, `attribute_scores` (aktiver Ordner) | `posts`, `jobs` (`bild_gen`) | ⚠️ `#create` Struktur da, ohne Ordnerwahl |
| **Generierung läuft** | Fortschritt der Kette, abbrechbar | `jobs` (Realtime) | | 🆕 |
| **Post-Ergebnis** | Kette prüfen, privat behalten oder veröffentlichen | `posts`, `post_images` | `posts.sichtbarkeit`, Zeilen-Permission | 🆕 |
| **Video: Szene** | Prompt + Videoanzahl + Ordner | `folders`, `attribute_scores` | `scenes`, `jobs` (`script`) | ⚠️ `#scene-create` ohne Ordnerwahl |
| **Video: Script** | Script bestätigen oder ändern (Diff = P6) | `scenes.script_md` | `scenes.script_final_md`, `jobs` (`video_gen`) | ✅ `#script-confirm` |
| **Video: Ergebnisse** | 36 Videos, Systemfavorit markiert | `videos` | `votes` | ✅ `#scene-results` |
| **Video: Vote** | Auswahl + optionale gezielte Frage | `questions` | `votes`, `questions.antwort``score_events` | ✅ `#scene-vote` |
| **Veröffentlichen** | Upload/Freigabe, danach Live-Performance | `video_metrics` | `videos.published` | ✅ `#publish` |
| **Modell erstellen** | Person / Produkt / **Kulisse** + Referenzen | | `assets`, `asset_versions`, `jobs` | ⚠️ `#model-create` Kulisse fehlt, kein echter Upload |
| **Modell-Ergebnis** | Freigeben oder verbessern | `asset_versions` | `assets.released_version_id` | ✅ `#model-result` |
| **Verbessern** (4 Screens) | Feedback → Versionen → Vote | `assets`, `asset_versions` | `asset_versions`, `score_events` | ✅ `#improve-*` |
| **Entwürfe** | Alles Unfertige **und alles Laufende** | `posts`, `scenes`, `assets` (Status ≠ fertig), `jobs` | | ⚠️ `#drafts` drei feste Karten |
> **Zwei fehlende Screens sind kritisch:** „Generierung läuft" und „Post-Ergebnis". Ohne sie gibt es keinen Ort, an dem der Nutzer wartet, und keinen Moment, an dem er sich bewusst fürs Veröffentlichen entscheidet dabei ist genau das laut Konzept ein aktiver Schritt.
### 4.5 Tab ③ Profil
| Screen | Zweck | Liest | Schreibt | Prototyp |
|---|---|---|---|---|
| **Eigenes Profil** | Kopf, Ordner, eigene Posts, Kennzahlen | `brands`, `folders`, `posts`, `follows` | | ⚠️ `#profile` Reihenfolge stimmt nicht, Zahlen fest |
| **Ordner-Liste** | Alle Ordner, aktiver hervorgehoben, neu anlegen | `folders` | `folders` | ⚠️ im Profil eingebettet |
| **Ordner anlegen** | Name, **Zweck**, **Startwerte** | `folders` | `folders` → Function `ordner-initialisieren` | 🆕 |
| **Ordner-Detail** | Posts im Ordner, Reifegrad, als aktiv setzen | `post_folders`, `posts`, `folders.signal_count` | `post_folders` | ⚠️ `#folder` nur Hülle |
| **Modelle** | Eigene Modelle nach Typ, Versionen | `assets`, `asset_versions` | | 🆕 (nur über Erstellen erreichbar) |
| **Knowledge Base** | Regeln (lesend) + Attribute mit Score **je Ordner** | `rules`, `attribute_scores` | | ⚠️ `#profile` ein globaler Score, kein Ordner-Scope |
| **Attribut-Detail** | Verlauf, Siege, Unter-Attribute | `attribute_scores`, `score_events` | | ⚠️ `#kb-detail` hartkodiert, nicht erreichbar |
| **Einstellungen** | Konto, verbundene Konten, Abo, Abmelden | `brands`, `usage_records` | `brands` | 🆕 |
| **Developer-Modus** | Regeln, Prompt-Einblick, Elo-Parameter | `rules`, `prompt_templates`, `jobs.prompt_sent` | | ⚠️ im Prototyp angelegt, Inhalt unbekannt |
**Zwei Änderungen gegenüber dem Prototyp:**
1. **Ordner nach oben.** Im Prototyp stehen sie unter den Metriken. Sie sind aber der Zugang zum wichtigsten Mechanismus der App und müssen ohne Scrollen erreichbar sein.
2. **Die Knowledge Base zeigt Scores je Ordner, nicht global.** Der Prototyp zeigt „Licht: warmes Abendlicht 6350". Richtig ist eine Ordner-Auswahl darüber derselbe Wert ist in zwei Ordnern verschieden.
---
## 5. Die drei Abläufe, an denen sich der Aufbau entscheidet
### 5.1 Post erstellen
```
→ Post
├─ Ordner wählen ─────────► bestimmt, welche attribute_scores gezogen werden
├─ Format + Kettenlänge
├─ Slots füllen (Kulisse, Person, Produkt aus eigenen assets)
├─ Werbetext
└─ Prompt
↓ posts-Zeile (status=entwurf) + n jobs-Zeilen (bild_gen)
„Generierung läuft" ◄── Realtime auf jobs
↓ P22 taggt jedes Bild → post_images.tags
Post-Ergebnis
├─ privat behalten → bleibt in Entwürfen
└─ veröffentlichen → sichtbarkeit=oeffentlich + Zeilen-Permission öffentlich
```
**Der Bruch gegenüber dem Prototyp:** Zwischen „Generieren" und „Ergebnis" liegt ein Wartezustand mit eigener Bildschirmdarstellung, der überlebt, wenn man die App verlässt.
### 5.2 Kopieren
```
Feed-Deck → Post-Detail → „Nachmachen"
▼ Kopier-Screen
Format 4:5 ✓ übernommen (kein LLM)
Kette 3 Bilder ✓ übernommen
Kulisse [Badezimmer, Morgenlicht] → behalten oder eigenes
Person [fremdes Modell] ⛔ PFLICHT → eigenes wählen
Produkt [kleine Kerze] ⛔ PFLICHT → eigenes wählen
Licht/Kamera/Farbe [weich seitlich…] → behalten oder ändern
Werbetext ✎ wird von P21 neu geschrieben
├─ Ordner wählen (Ziel des Imports)
Create (vorbefüllt) → wie 5.1
Function feed-import: Slots als Attribute in attribute_scores des Ordners
post_metrics.kopien +1 beim Original
```
**Warum ein eigener Screen:** Der Nutzer muss vor dem Generieren sehen, was aus dem Original stammt und was seins ist. Springt man direkt in Create, verschwimmt das und die Pflicht, eigene Modelle einzusetzen, wird zu einer Fehlermeldung statt zu einer sichtbaren Regel.
### 5.3 Ordner wechseln
Der aktive Ordner wird im Profil oder beim Erstellen gesetzt und bleibt bestehen. Beim Wechsel ändert sich **nichts an bestehenden Posts** nur die nächste Generierung zieht aus einem anderen Scope.
Sichtbar sein muss: **welcher Ordner aktiv ist** (Kopfzeile im Erstellen-Bereich) und **wie reif er ist** („4 Posts lernt noch"). Ohne diese zwei Angaben ist für den Nutzer nicht erklärbar, warum zwei gleiche Prompts verschiedene Bilder ergeben.
---
## 6. Abgleich mit dem Prototyp
### Was bleibt
Die komplette **Video-Strecke** (`scene-create``script-confirm``scene-results``scene-vote``publish`) und die **Verbessern-Strecke** (`improve-*`) sind schlüssig und decken sich mit dem Konzept. Ebenso das **Swipe-Deck** und die **3D-Ordner-Darstellung**.
### Was sich ändern muss
| Prototyp | Muss werden |
|---|---|
| Onboarding: 5 Felder ohne `id`/`name` | 6 Fragen inkl. Nische, Werte werden gespeichert |
| Modell-Typen: Person / Produkt | Person / Produkt / **Kulisse** |
| Ordner unten im Profil | Ordner oben, mit Zweck- und Startwert-Schaltern beim Anlegen |
| Knowledge Base mit einem globalen Score | Score je Ordner, mit Ordner-Auswahl |
| „Generieren" = Screenwechsel | Job + Wartezustand + Realtime |
| Entwürfe: drei feste Karten | echte Liste aus `posts`/`scenes`/`assets` + laufende Jobs |
| „Nachmachen" als Button | eigener Kopier-Screen |
| Uploads als Attrappen | echte Datei-Uploads in die Buckets |
| Fake-Tastatur, `inputmode="none"` | native Eingabe die Fake-Tastatur ist ein reines Design-Requisit und darf nicht in die App |
### Was komplett fehlt
Willkommen · Registrieren/Anmelden · Kopier-Screen · Fremdprofil · Ordner anlegen · Modelle-Übersicht · Generierung läuft · Post-Ergebnis · Einstellungen.
---
## 7. Neu wegen des Folgen-Konzepts
Der Social Graph war in keinem Dokument enthalten und bringt eigenen Bedarf mit:
**Datenbank:** neue Tabelle `follows` (`follower_brand_id`, `followed_brand_id`, unique auf beide, Index auf beide Richtungen). Dazu auf `brands` öffentliche Felder: `anzeigename`, `avatar_file_id`, `follower_count`, `ist_oeffentlich`.
**Feed-Reiter „Folge ich":** eigene Abfrage Posts der gefolgten Brands, **chronologisch statt nach Feed-Score**. Wer jemandem folgt, will dessen Neues sehen, nicht dessen Bestes von vor drei Monaten.
**Rechtlich:** Ein öffentliches Profil mit Folgen-Funktion ist ein soziales Netzwerk im Kleinen. Nötig werden damit: **Blockieren**, **Melden** (DSA) und eine Entscheidung zum **Mindestalter**. Das steht so noch in keinem Dokument und gehört vor dem Livegang geklärt, nicht danach.
---
## 8. Offene Punkte
| # | Punkt | Blockiert |
|---|---|---|
| 1 | **Bildmodell** noch nicht ausgewählt | jede echte Generierung |
| 2 | **Nischen- und `typ_tags`-Enums** nicht festgelegt | Kopier-Kompatibilität, Feed-Filter, P19, P22 |
| 3 | **Preismodell / Credits** | Einstellungen-Screen, `usage_records`, Stripe |
| 4 | **Moderation und Melden** | Livegang des öffentlichen Feeds |
| 5 | **Desktop-Ansicht** | im Repo als Handoff vorhanden, laut dir noch nicht gültig |
| 6 | **Developer-Modus** | Inhalt des Prototyp-Screens ist unbekannt (Datei war beim Abruf abgeschnitten) |

308
planung/datenbank-aufbau.md Normal file
View File

@@ -0,0 +1,308 @@
# Datenbank-Aufbau (Appwrite, self-hosted)
**Stand:** Juli 2026 · basiert auf Appwrite TablesDB (Tabellen/Zeilen/Relationen), Functions, Storage mit S3-Adapter, Realtime, Auth + Teams, Messaging.
**Grundsatz:** Die `brand-knowledge/`-Ordnerstruktur aus dem Konzept wird 1:1 in Tabellen abgebildet. Die .md-Textebenen (Beschreibung, Essenz, Details) bleiben als Markdown-Felder erhalten strukturierte Daten (Scores, Zähler, Status) werden eigene Spalten, damit man danach filtern und sortieren kann.
> **Update Juli 2026 Bild-Modus, Feed & Ordner** (Konzept: `konzept-bilder-feed.md`)
> Zwei strukturelle Änderungen, keine reinen Ergänzungen:
> 1. **`attributes` ist aufgetrennt.** Scores und Zähler liegen jetzt in `attribute_scores` ein Attribut trägt **mehrere Scores, einen je Ordner**. Ohne diesen Schnitt gäbe es keine ordnerbezogene Wissens-Segmentierung.
> 2. **`assets` heißt fachlich „Modelle"** und kennt den neuen Typ `kulisse`. Der Begriff „Modell" meint in diesem Projekt durchgehend das Asset (Person, Produkt, Kulisse) **nie** das KI-Modell (Seedance, Veo, Sora). Diese Sprachregelung gilt für Doku und Code.
>
> Neu dazu: `folders`, `attribute_scores`, `posts`, `post_images`, `post_folders`, `post_metrics`.
---
## 1. Appwrite-Features → unsere Nutzung
| Appwrite-Feature | Wofür wir es nutzen |
|---|---|
| **Auth + Teams** | Jede Brand = ein Team. Zeilen-Rechte pro Team → sauberes Multi-Tenant ohne eigene Logik. Internes Admin-Team für den Dev-Modus. |
| **TablesDB + Relationen** | Alle Tabellen unten; 4 Relationstypen decken Kategorie→Attribut, Attribut→Unter-Attribut (self-relation), Szene→Videos ab. |
| **Storage (S3-Adapter!)** | Buckets laufen direkt auf **Hetzner Object Storage** als Backend Appwrite verwaltet Rechte/Uploads, Hetzner speichert. Kein doppeltes System. |
| **Functions** | Alle deterministischen Abläufe (Elo, Cron-Jobs) + Auslöser für LLM-Jobs. Event-getriggert oder geplant. |
| **Realtime** | Live-Status in der App: „Script fertig → generiere Video 2/4 → Analyse läuft" ohne Polling. |
| **Messaging** | Push/E-Mail: „Deine 4 Videos sind fertig wähle das Beste." |
---
## 2. Tabellen
### Mandant & Konfiguration
**`brands`** eine Zeile pro Kunde
| Spalte | Typ | Hinweis |
|---|---|---|
| team_id | string | Appwrite-Team (Rechte) |
| label_name, produkte, zielgruppe, brand_worte[3], am_markt_seit | string | aus dem Onboarding (6 Pflichtfragen) |
| **nische** | enum: kosmetik/auto/gastro/fitness/mode/… | **neu** feste Branchenliste, Pflichtfrage im Onboarding. Filtert den Feed und steuert die Kopier-Kompatibilität |
| stripe_customer_id | string | Payment |
| default_video_count | int (36) | einstellbare Videoanzahl |
| **plan** | enum: bild/video | **neu** Bild-Modus ist die günstige Einstiegsstufe (Preismodell noch offen) |
| status | enum: trial/aktiv/pausiert | |
**`rules`** feste Regeln (Dev-Modus) 🔒 *nur internes Team*
| Spalte | Typ |
|---|---|
| brand_id | rel → brands (null = global für alle Brands) |
| titel, prompt_text | string |
| aktiv, sortierung | bool, int |
**`prompt_templates`** die statischen Prompts P1P22 (Dev-Modus) 🔒 *nur internes Team*
| Spalte | Typ | Hinweis |
|---|---|---|
| key | enum P1…**P22** | siehe Prompt-Inventar (P19P22 = Bild-Modus & Feed) |
| version | int | Prompts sind versioniert man muss zurückrollen können |
| static_core_md | string | der statische Kern |
| slots | json | welche Slots injiziert werden (REGELN, ASSETS, …) |
| model_adapter | enum: null/seedance/veo/sora/wan | P8-Familie: ein Kern, mehrere Dialekte |
| aktiv | bool | |
### Knowledge Base
**`folders`** Wissens-Scope (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel → brands | **privat pro Brand** Ordner sind kein soziales Objekt |
| name, theme_md | string, string (Markdown) | „Dark Studio"; theme_md schreibt P20 beim Anlegen |
| **zweck** | enum: sammlung / wissens_scope | `sammlung` = nur kategorisieren, keine Scores, kein Einfluss auf Generierung. `wissens_scope` = aus dem Ordner heraus wird erstellt |
| **startwert_modus** | enum: erben / aus_posts | nur bei `wissens_scope`. `erben` = Kopie der brand-weiten Scores beim Anlegen · `aus_posts` = nur was in den gesammelten Posts steckt, Startwert = Kategorie-Ø **innerhalb des Ordners** |
| ist_default | bool | Anzeige-Ordner „Allgemein" im UI. **Die brand-weite Basisebene ist keine Ordner-Zeile, sondern `folder_id = null` in `attribute_scores`** der Default-Ordner zeigt sie nur an, er speichert sie nicht |
| post_count, signal_count | int | Reifegrad steuert den K-Faktor und die UI-Anzeige „Ordner lernt noch" |
| created_at | datetime | |
> **Kaltstart:** keine Mindestanzahl an Posts, keine Sperre. Ein Ordner ist ab dem Anlegen nutzbar; die statistische Unsicherheit steckt im K-Faktor (32 → 8), nicht in einer Sperre. Das ist dieselbe Regel wie in `projekt-uebersicht.md` §5, nur auf Ordner-Ebene.
**`categories`**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| name | string (location, licht, farben, kamera, voice, geraeusche, texte-hooks, handlungen) | |
| **folder_id** | rel → folders (null = brand-weit) | **neu** `avg_score` ist scope-abhängig, also gibt es die Zeile je Ordner |
| avg_score | int | **Cache für Startwerte neuer Einträge** per Function aktualisiert, nicht live berechnet |
| attribute_count | int | |
**`attributes`** nur noch die **Definition** (Scores siehe unten)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, category_id | rel | |
| parent_id | rel → attributes (self) | Unter-Attribute (kaiserslautern--stadion) |
| name, slug | string | |
| status | enum: aktiv/archiviert | **nie löschen** |
| tags | string[] | Volltext-Index |
| beschreibung_md, essenz_md, details_md | string (Markdown) | die drei Textebenen |
| prompt_bausteine, negativ_prompts | string[] | gehen direkt in P7/P8-Slots |
> **Warum aufgetrennt:** Ein Attribut („badezimmer") ist einmal definiert, trägt aber **mehrere Scores** einen brand-weit und je einen pro Ordner. „Warmes Abendlicht" kann im Ordner *Sommerkampagne* auf 6800 stehen und im Ordner *Dark Studio* gar nicht existieren. Die Textebenen bleiben bewusst am Attribut, nicht am Score: die Beschreibung ändert sich nicht mit dem Scope.
**`attribute_scores`** Score je Attribut je Ordner (neu, Herzstück)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel → attributes | |
| **folder_id** | rel → folders (**null = brand-weite Basisebene**) | Unique-Constraint auf (attribute_id, folder_id) |
| brand_id, category_id | rel | denormalisiert. **Index: (brand, folder, category, score ↓)** das ist die Top-N-Abfrage, die P4 bei jeder Generierung fährt |
| score | int (010.000) | |
| k_factor | int | 32 neu → 8 etabliert; bleibt hoch, solange `folders.signal_count` niedrig ist |
| start_value, start_quelle | int, enum: kategorie_avg / geerbt / feed_import | dokumentiert, woher der Startwert kam ohne das ist später nicht nachvollziehbar, warum ein Score dort steht |
| used_count, wins, losses | int | |
| last_used_at | datetime | |
**`score_events`** das Log (append-only, nie ändern)
| Spalte | Typ | Hinweis |
|---|---|---|
| attribute_id | rel | Index: (attribute, created_at) |
| **folder_id** | rel → folders (null = brand-weit) | **neu und zwingend** ohne den Scope ist nicht nachvollziehbar, in welcher Wissensinsel ein Punkt vergeben wurde |
| video_id, **post_id**, opponent_attribute_id | rel | |
| event_type | enum: vote / vote_favorit_bestaetigt / performance / gezielte_frage / llm_vergleich / **feed_import** / **post_reichweite** | die zwei neuen Typen kommen aus dem Bild-Modus |
| delta, new_score, weight | int, int, float | weight: ×0,3 / ×1 / ×2 · `feed_import` und `post_reichweite` laufen mit **×0,3** |
| kommentar | string | z. B. P12-Erkenntnis („Stadion war der Unterschied") |
### Assets / „Modelle" (Release-Prinzip)
**`assets`** im UI **„Modelle"**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| typ | enum: gesicht/produkt/**kulisse**/logo/sonstiges | **`kulisse` ist neu** die Umgebung ist ein eigenständiges, einzeln austauschbares Modell |
| name | string | „kleine Kerze" |
| **nische** | enum wie `brands.nische` | Feed-Filter |
| **typ_tags** | string[] (Enum-Zwang) | fahrzeug, getraenk, tube, person_weiblich, innenraum, … **gegen diese Liste prüft die Slot-Kompatibilität beim Kopieren**. Feste Liste + Code-Validator, keine freie KI-Vergabe |
| **feed_score, feed_score_updated_at** | float, datetime | Modelle werden getrennt von Posts gerankt (siehe `post_metrics`) |
| **ist_teilbar** | bool (default **false**) | Modelle werden grundsätzlich **nicht** geteilt. Das Feld existiert nur, um die Regel explizit und prüfbar zu machen |
| released_version_id | rel → asset_versions (**nur diese wird in Videos und Bildern verwendet**) | |
> **Regel:** Ein Kopierer bringt immer eigene Modelle mit. Fremde Modelle erscheinen nur als **Beschriftung** im Kopier-Screen („Modell: kleine Kerze"), damit klar ist, was das Rezept braucht nie als nutzbares Asset. Begründung in `konzept-bilder-feed.md` §3 (Marken-/Designrecht bei Produkten, Konsistenzentwertung bei Personen).
**`asset_versions`** Versionskette
| Spalte | Typ | Hinweis |
|---|---|---|
| asset_id, version_no | rel, int | |
| status | enum: entwurf/freigegeben/archiviert | |
| beschreibung_md, merkmale | string, string[] | |
| change_reason | string | „Haare dunkler, natürlicher" |
| won_against_version_id | rel | Vorher/Nachher-Vote |
| reference_file_ids | string[] | → Storage-Bucket |
| einwilligung_file_id | string | dokumentierte Einwilligung bei echten Gesichtern |
### Produktion
**`scenes`**
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | |
| **folder_id** | rel → folders (null = brand-weit) | **neu** der Scope wird **vor** der Generierung gewählt und bestimmt, welches Wissen P4 zieht |
| user_prompt | string | Original, unverändert aufheben |
| selected_context | json | was P4 injiziert hat (Attribute + Scores zum Zeitpunkt!) |
| script_md, script_final_md | string | Original vs. vom Nutzer bearbeitet → Diff = P6-Signal |
| status | enum: entwurf/script/generiert/voting/fertig | **Realtime-Kanal fürs UI** |
| video_count | int | |
**`videos`**
| Spalte | Typ | Hinweis |
|---|---|---|
| scene_id, brand_id | rel | |
| ki_model | enum: seedance/veo/sora/wan | |
| prompt_sent | string 🔒 | exakt was ans KI-Modell ging (Dev-Modus „Prompt-Einblick") |
| storage_file_id | string | → Bucket generated-videos |
| tags | json | P9-Analyse |
| attribute_ids | rel many→many | für Attribution |
| asset_version_ids | string[] | welche Versionen genau (v3? v4?) |
| is_system_favorite, vote_result | bool, enum: gewonnen/verloren/ | |
| published, platforms, published_at | bool, string[], datetime | |
**`video_metrics`** Zeitreihe (Cron-Function, Ads-APIs)
| Spalte | Typ |
|---|---|
| video_id, fetched_at | rel, datetime |
| ctr, thumbstop, roas, follower_gained, spend | float |
**`votes`**
| Spalte | Typ |
|---|---|
| scene_id, winner_video_id | rel |
| favorit_bestaetigt | bool (→ Gewichtung ×0,3) |
| created_at | datetime |
**`questions`** gezielte Fragen (P13)
| Spalte | Typ |
|---|---|
| brand_id, scene_id | rel |
| frage, attribute_a_id, attribute_b_id | string, rel, rel |
| antwort | enum: a/b/egal/offen |
### Bild-Modus: Posts, Feed & Ordner
**`posts`** das Slot-Rezept (neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id | rel | Ersteller |
| **format** | enum: 1_1 / 4_5 / 9_16 | gilt für die ganze Kette. Beim Kopieren **deterministisch** übernommen kein LLM |
| **nische** | enum wie `brands.nische` | Feed-Filter |
| kulisse_asset_id, person_asset_id, produkt_asset_id | rel → assets | die Modell-Slots |
| asset_version_ids | string[] | welche Versionen genau wie bei `videos` |
| attribute_ids | rel many→many → attributes | Licht / Kamera / Farbe als Slots |
| werbetext | string | Inhalt des Text-Overlay-Bildes |
| prompt_sent | string 🔒 | exakt was ans Bildmodell ging **nur internes Team**, nie im Kopier-Screen |
| **slot_summary** | json | die vereinfachte Chip-Darstellung für den Kopier-Screen (Output von P19). **Das ist das Einzige, was ein fremder Nutzer sieht** eine Abstraktion, keine Offenlegung |
| **copied_from_post_id** | rel → posts (self) | Herkunftskette erlaubt später „dieses Rezept wurde 400× kopiert" |
| **sichtbarkeit** | enum: privat / oeffentlich (default **privat**) | Veröffentlichen ist ein aktiver Schritt |
| published_at | datetime | |
| social_permalink, social_plattform | string, enum: instagram/tiktok/… | Grundlage für den Reichweiten-Abgleich |
| **feed_score, feed_score_updated_at** | float, datetime | Cache, per Cron berechnet nie live |
| ki_kennzeichnung | bool | EU AI Act im Feed sichtbar, nicht nur beim Export |
**`post_images`** die Bilderkette
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, position | rel, int | Reihenfolge im Karussell |
| typ | enum: motiv / **text_overlay** | Das Text-Overlay ist ein **eigenes Bild**, keine Ebene auf dem Motiv so lässt sich der Text tauschen, ohne die Motive neu zu generieren |
| storage_file_id | string | → Bucket `generated-images` |
| winkel, position_beschreibung | string | über die Kette variiert **nur** Position/Winkel; Modelle, Kulisse, Licht, Farbe und Text bleiben konstant |
| tags | json | P22-Analyse (Bild-Tagger) |
**`post_folders`** Zuordnung (m:n, neu)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, folder_id | rel, rel | Unique auf (post_id, folder_id) |
| **ist_fremd** | bool | Post stammt nicht vom Ordner-Besitzer. Ordner nehmen **eigene und fremde** Posts auf Wissen entsteht durch Sammeln, nicht erst durch Ausgeben |
| quelle | enum: eigen / feed_gespeichert / kopiert | |
| zugeordnet_von | enum: nutzer / p20_vorschlag | P20 schlägt vor, der Nutzer bestätigt |
| created_at | datetime | |
**`post_metrics`** Feed-Signale (Zeitreihe, append-only)
| Spalte | Typ | Hinweis |
|---|---|---|
| post_id, fetched_at | rel, datetime | |
| views, likes, **kopien** | int | In-App-Signale |
| social_reach, social_engagement | int | von verbundenen Accounts |
| ist_verifiziert | bool | nur verifizierte Konten zählen voll Manipulationsschutz |
> **Gewichtung Feed-Score:** Views ×0,1 · Likes ×0,5 · **Kopien ×1** · Social-Reichweite ×2. Eine Kopie wiegt mehr als ein Like ein Like ist eine Meinung, eine Kopie eine Handlung mit Aufwand.
>
> **Der Feed-Score ist kein Elo.** Elo braucht Duelle; hier werden Zähler aufsummiert. Deshalb eigene Tabelle, eigener Name, eigene Function. Details und Begründung: `konzept-bilder-feed.md` §8.
---
### Betrieb
**`jobs`** jede KI-Aktion als Zeile (Warteschlange + Kostenkontrolle)
| Spalte | Typ | Hinweis |
|---|---|---|
| brand_id, typ | rel, enum: bild_gen/video_gen/tagging/vergleich/script/**slot_analyse**/**ordner_vorschlag**/**werbetext** | die drei neuen Typen kommen aus dem Bild-Modus (P19, P20, P21) |
| prompt_template_key, prompt_template_version | string, int | **welcher Prompt in welcher Version lief** ohne das ist kein Prompt-Debugging möglich |
| status | enum: wartend/läuft/fertig/fehler | |
| cost_usd, tokens | float, int | speist die Beispielrechnung mit echten Zahlen |
| error, refs | string, json | |
**`usage_records`** für Stripe (metered billing: Preis pro Video)
| Spalte | Typ |
|---|---|
| brand_id, periode, videos_generiert, kosten_usd | rel, string, int, float |
---
## 3. Storage-Buckets (Backend: Hetzner Object Storage via S3-Adapter)
| Bucket | Inhalt | Rechte |
|---|---|---|
| `uploads` | Onboarding: Logo, Produktbilder, Top-Ads; Referenzen beim Verbessern | Team der Brand |
| `asset-references` | generierte Referenzbilder der Asset-Versionen | Team der Brand |
| `generated-videos` | alle generierten Videos | Team der Brand |
| **`generated-images`** | alle generierten Bilder (Motive + Text-Overlays) | Team der Brand; **öffentlich lesbar, sobald `posts.sichtbarkeit = oeffentlich`** |
| `consents` | Einwilligungs-Dokumente (Gesichter) | Team + intern |
---
## 4. Functions (deterministisch die ⚙️-Punkte aus dem Prompt-Inventar)
| Function | Trigger | Macht |
|---|---|---|
| `elo-update` | Event: votes.create / video_metrics.create | Elo-Mathe, K-Faktor, Gewichtung, schreibt score_events **jetzt scope-bewusst: schreibt in `attribute_scores` der passenden `folder_id`** |
| `favorit-berechnen` | Event: alle Videos einer Szene getaggt | Score-Summe → is_system_favorite |
| `ads-metriken-holen` | Cron täglich | Meta/TikTok-API → video_metrics |
| `kategorie-durchschnitt` | Cron täglich | avg_score-Cache für Startwerte **je Ordner** |
| `archivierung` | Cron wöchentlich | Score unter Schwelle + inaktiv → Status archiviert |
| `job-dispatcher` | Event: jobs.create | ruft OpenRouter, schreibt Ergebnis + Kosten zurück |
| **`ordner-initialisieren`** | Event: folders.create | Bei `startwert_modus = erben` die brand-weiten Scores nach `attribute_scores` kopieren; bei `aus_posts` nur die Attribute der zugeordneten Posts anlegen, Startwert = Kategorie-Ø im Ordner. Setzt `start_quelle` |
| **`feed-score-berechnen`** | Cron stündlich | Gewichtete Summe aus `post_metrics` + **exponentieller Zeit-Decay** (Halbwertszeit-Parameter, Startwert 30 Tage) → `posts.feed_score` und aggregiert → `assets.feed_score`. **Rein deterministisch, kein LLM** |
| **`feed-import`** | Event: post kopiert | Legt die Slots des Originals als Attribute in der KB des Kopierers an (Scope = gewählter Ordner), `start_quelle = feed_import`, schreibt `score_events` |
| **`social-reichweite-holen`** | Cron täglich | Instagram/TikTok-API → `post_metrics.social_reach`; löst Rückfluss mit ×0,3 in die KB aus |
---
## 5. Rechte-Modell
- **Brand-Team:** sieht nur eigene Zeilen (Appwrite Row-Level-Permissions pro Team) Multi-Tenant ohne Extra-Code.
- **Internes Team (Dev-Modus):** exklusiver Zugriff auf `rules`, `prompt_templates`, `jobs.prompt_sent`, **`posts.prompt_sent`** das App-Geheimnis liegt in den Rechten, nicht nur im UI.
- **append-only:** `score_events`, `video_metrics`, **`post_metrics`**, `jobs` werden nie geändert oder gelöscht das ist die Log-Anforderung aus dem Konzept.
**Neu mit dem Feed die einzige öffentlich lesbare Ebene:**
| Öffentlich lesbar | Bleibt privat |
|---|---|
| `posts` bei `sichtbarkeit = oeffentlich`: Bilder, `slot_summary`, `werbetext`, `nische`, `feed_score` | `posts.prompt_sent` 🔒 · `attribute_scores` · `folders` (immer privat) · `rules` · alle Modelle als **Assets** |
| `assets`: nur Name, Typ und Typ-Tags als **Beschriftung** im Kopier-Screen | die Asset-Version selbst, Referenzbilder, `einwilligung_file_id` |
> Das ist die kritische Grenze des ganzen Features: Der Kopier-Screen zeigt **`slot_summary`**, nie `prompt_sent`. Eine Abstraktion, keine Offenlegung sonst wandert die Knowledge Base fremder Brands nach außen.
**Zusätzlich nötig, weil der Feed öffentlich ist:** Rate-Limits auf `post_metrics`-Signale pro Konto und eine Melde-/Moderationsfunktion (DSA). Beim geschlossenen B2B-Produkt gab es beides nicht.

View File

@@ -0,0 +1,72 @@
# Fragenkatalog: Unternehmensprofil (Onboarding)
**Wann:** Erscheint einmalig beim ersten Klick auf „Erstellen" nie beim App-Start.
**Ablauf:** Tutorial-artig Fullscreen-Intro mit kurzem Text → Weiter → Formularseite → Fullscreen-Intro → nächste Seite. Kein Formular-Block am Stück.
**Prinzip:** Nur 6 Pflichtfragen (unter 2 Minuten). Alles Weitere ist optional und jederzeit im Profil nachtragbar. Jede Antwort wird direkt in die Brand Knowledge übersetzt (Regel, Attribut oder Asset).
---
## Stufe 1: Pflicht (6 Fragen)
| # | Frage | Beispiel | Wird in der Knowledge Base zu … |
|---|---|---|---|
| 1 | Wie heißt dein Label? | VELVET ROUGE Cosmetics | Brand-Name (Regel: korrekte Schreibweise in jedem Video) |
| 2 | Was verkaufst du? | Vegane Lippenstifte & Lipgloss | Produkt-Kategorie (Basis für Produkt-Assets) |
| 3 | **In welcher Branche bist du?** (Auswahl aus fester Liste) | Kosmetik · Auto · Gastro · Fitness · Mode · … | **`brands.nische`** keine Attribut-, sondern eine **Struktur-Angabe**. Steuert den Feed-Filter und die Kopier-Kompatibilität |
| 4 | Wen willst du erreichen? | Frauen 1835, TikTok & Instagram | Zielgruppen-Attribut (beeinflusst Tonalität, Tempo, Musik) |
| 5 | Welche 3 Worte beschreiben deine Brand? | mutig, clean, luxuriös | Stil-Attribute (Startwerte fürs Scoring) |
| 6 | Wie lange seid ihr schon auf dem Markt? | seit 2021 | Tonalitäts-Kontext (etabliert vs. neu → beeinflusst Storytelling, z. B. „seit 5 Jahren" als Trust-Element) |
> **Warum die Nische Pflicht ist und nicht Progressive Profiling:** Ohne sie ist der Feed beim ersten Öffnen unbrauchbar ein Autohaus bekäme Kosmetikstudio-Werbung vorgeschlagen. Und die Slot-Kompatibilität beim Kopieren (`konzept-bilder-feed.md` §4) lässt sich ohne Nische gar nicht prüfen. Sie ist eine Auswahl aus einer festen Liste, kostet also keine 5 Sekunden.
---
## Stufe 2: Uploads (optional, empfohlen)
*Uploads erzeugen **Modelle** der Sammelbegriff für Person, Produkt, Kulisse und Logo (`assets`). „Modell" meint nie das KI-Modell.*
| # | Frage / Aufforderung | Zweck |
|---|---|---|
| 7 | Lade dein Logo hoch | Logo-Modell + Regel „nie verzerren/umfärben" |
| 8 | Lade Produktbilder hoch (ideal: 3+ Winkel pro Produkt) | Produkt-Modell für 1:1-Konsistenz. **46 Referenzbilder sind optimal, mehr als 7 führen zu Feature-Averaging** (`prompt-templates-research.md`) |
| 9 | Lade deine bisher besten Ads hoch (Video/Bild) | System extrahiert Startattribute (Farben, Tempo, Hooks) → Knowledge Base startet nicht bei null |
| 10 | Gibt es ein Gesicht deiner Brand? (Foto-Upload oder „KI-Gesicht erstellen") | Personen-Modell; bei echter Person: Einwilligung dokumentieren. **KI-Gesichter sind der Standard** |
| 11 | **Wo soll gedreht/fotografiert werden?** (Beschreibung oder Referenzbild) | **Kulissen-Modell** neu, weil die Umgebung im Bild-Modus ein eigenständig austauschbarer Slot ist |
---
## Feste Regeln: NICHT Teil des Onboardings
Feste Regeln (cleaner Hintergrund, „nur zeigen, was verlangt ist", Logo-Schutz …) werden **nicht vom Nutzer** verwaltet. Sie liegen im versteckten **Developer-Modus** (nur intern zugänglich) sie sind das Feintuning und Betriebsgeheimnis der App, nicht eine Nutzer-Einstellung. Der Nutzer profitiert davon, ohne sie je zu sehen.
---
## Stufe 3: Später nachfragbar (Progressive Profiling)
Diese Fragen stellt das System **nicht** im Onboarding, sondern einzeln im passenden Moment (max. 1 Frage pro Sitzung):
| Frage | Wann sinnvoll |
|---|---|
| Welche Farbwelt bevorzugst du? (Palette wählen) | Vor der 2. Szene |
| Soll gesprochen werden, Text-Overlay oder beides? | Nach dem ersten Video-Vote |
| Gibt es No-Go-Themen oder -Bilder? | Beim ersten Verbessern |
| Preissegment deiner Produkte? (Budget / Mid / Luxus) | Wenn Tonalität-Attribute unklar scoren |
| Welche Städte/Locations passen zur Brand? | Wenn Location-Kategorie leer ist |
| Ad-Konten verbinden (Meta / TikTok)? | Nach dem ersten Upload eines Videos **wichtigster Moment**, hier beginnt der Performance-Loop |
| **Social-Konto verbinden (Instagram / TikTok)?** | Nach der ersten **Veröffentlichung eines Posts** der Moment, in dem der Nutzer wissen will, wie er performt. Speist den Feed-Score (×2) und den KB-Rückfluss (×0,3). **Eigene App-Review, getrennt von den Ads-Berechtigungen** |
| Wettbewerber, an denen du dich orientierst? | Optional, fürs Referenz-Verständnis |
---
## Übersetzungslogik (intern)
Jede Antwort landet als Eintrag in der Brand Knowledge:
- **Regel** → gilt immer, kein Score verwaltet nur im Developer-Modus (intern)
- **Attribut** → Elo-Score, Start beim Kategorie-Durchschnitt bzw. 5000 beim ersten Eintrag (Stufe 1 Fragen 46, Stufe 3). Landet in `attribute_scores` mit `folder_id = null`, also auf der **brand-weiten Basisebene**
- **Modell (Asset)** → versioniert, Release-Prinzip (Stufe 2: Logo, Produkt, Gesicht, Kulisse)
- **Struktur-Angabe** → kein Score, keine Regel: `brands.nische` steuert nur Filter und Kompatibilität (Stufe 1 Frage 3)
**Grundsatz:** Kein Formular-Marathon. Die Knowledge Base wächst durch Nutzung (Votes, Performance, gesammelte Posts), nicht durch Abfragen der Fragenkatalog liefert nur den Startpunkt.
**Neuer, schnellerer Startpunkt:** Im Bild-Modus muss die KB gar nicht mehr über Fragen gefüllt werden. Wer einen Post aus dem Feed kopiert, importiert dessen Slots direkt als Attribute (`konzept-bilder-feed.md` §10). Das Onboarding bleibt trotzdem nötig für Nische, Modelle und Tonalität , ist aber nicht mehr die einzige Quelle für den Kaltstart.

View File

@@ -0,0 +1,290 @@
# Bild-Modus, Feed & Ordner Konzept
**Stand:** 25. Juli 2026
**Verhältnis zur bestehenden Doku:** Erweiterung von `projekt-uebersicht.md`. Der dort beschriebene Video-Loop bleibt **unverändert bestehen**. Dieses Dokument beschreibt einen **zweiten, entkoppelten Produktteil**, der daneben läuft und an genau drei definierten Stellen andockt (Ordner-Scope, KB-Import, gemeinsame Modelle).
---
## 1. Warum überhaupt ein zweiter Modus
Der Video-Loop hat zwei Probleme, die im Konzept offen stehen (`projekt-uebersicht.md` §11):
1. **Kaltstart.** Runde 1 startet ohne Wissen. Die Knowledge Base wird erst durch Votes und Ad-Performance nützlich also nachdem Geld ausgegeben wurde.
2. **Einstiegshürde.** Video ist teuer (>90 % der Betriebskosten) und setzt eine Brand mit Ad-Budget voraus.
Bilder lösen beides: Sie sind um Größenordnungen billiger, brauchen keine große Knowledge Base und öffnen das Produkt für eine breitere Nutzergruppe (Creator, kleine Labels, Privatnutzer). Der Feed liefert zusätzlich fertige Rezepte man startet nicht bei null, sondern bei etwas, das nachweislich funktioniert.
**Der Bild-Modus ist bewusst entkoppelt:** kein Wettbewerb mehrerer KI-Modelle, kein Vote-Loop, ein festes Bildmodell. Er ist der günstige Einstieg, nicht das Kernprodukt. Wer den Lern-Loop will, geht auf Video.
---
## 2. Anatomie eines Posts
Ein Post ist **kein Freitext-Prompt**, sondern ein **Slot-Rezept**. Das ist die zentrale Designentscheidung nur weil ein Post aus benannten Slots besteht, ist er überhaupt kopierbar.
| Slot | Inhalt | Beim Kopieren |
|---|---|---|
| **Format** | 1:1 · 4:5 · 9:16 | deterministisch übernommen kein LLM nötig |
| **Kulisse** | Umgebung als eigenes Modell (siehe §3) | eigenes Modell einsetzen |
| **Modelle** | Person und/oder Produkt | **immer eigene** nie die des Erstellers |
| **Licht / Kamera / Farbe** | Attribute, wie im Video-Teil | übernehmen oder ändern |
| **Werbetext** | Text-Overlay | wird von der KI neu geschrieben (siehe §7) |
| **Nische** | Branche des Posts | bestimmt Kompatibilität (siehe §4) |
Zusätzlich gehört zum Post: die **Bilderkette** (§5) und nach Veröffentlichung der **Feed-Score** (§8).
### Warum Slots und nicht Freitext
Ein kopierter Freitext-Prompt wäre für den Kopierer eine Blackbox: Er sieht Wörter, weiß aber nicht, welches Wort welchen Bildteil steuert, und kann gezielt nichts austauschen. Slots machen das Rezept lesbar und teilbar, ohne dass der Original-Prompt offengelegt werden muss.
---
## 3. Modelle
**„Modell" bedeutet in diesem Projekt durchgehend das Asset (Person, Produkt, Kulisse) nicht das KI-Modell** (Seedance, Veo, Sora). Diese Sprachregelung gilt für die gesamte Doku und den Code.
### Die Typen, einzeln tauschbar
| Typ | Was es ist | Beispiel |
|---|---|---|
| **Person** (DB: `gesicht`) | KI-generiertes Gesicht/Körper, konsistent über Posts | die Person, die das Produkt hält |
| **Produkt** | Das eigene Produkt, konsistent abgebildet | „kleine Kerze" |
| **Kulisse** | Umgebung/Location als eigenständiges Asset **neu** | Badezimmer, Betonstudio, Strand |
| **Logo** | wie bisher, kein Post-Slot wird über Regeln eingebunden | |
Die drei erstgenannten sind die **Post-Slots**. Das Logo bleibt wie im Video-Teil ein Asset ohne eigenen Slot.
Das System komponiert daraus ein Bild: *Person hält Produkt in Kulisse.* Jeder Teil ist einzeln austauschbar das ist die Voraussetzung dafür, dass Kopieren funktioniert.
> **Technischer Hinweis:** Multi-Referenz-Komposition (drei Assets in einem Bild, alle konsistent) ist der anspruchsvollste Teil des Bild-Modus. Die Erkenntnisse aus `prompt-templates-research.md` gelten hier direkt: 46 Referenzbilder pro Asset, Identity-Lock am Anfang **und** Ende des Prompts, Token-Locking (Merkmale wörtlich speichern und wörtlich wiederverwenden).
### Modelle werden nie geteilt
Ein Kopierer bringt **immer seine eigenen Modelle mit**. Das ist keine Bequemlichkeitsfrage, sondern rechtlich zwingend:
- **Produkt-Modelle** bilden echte Markenprodukte ab. Ein fremdes Produkt-Modell zu übernehmen und damit zu werben ist Marken- und Designrecht nicht nur eine Hausregel.
- **Personen-Modelle** sind zwar KI-generiert (Standard nach `projekt-uebersicht.md` §10), aber ein geteiltes Gesicht über viele Brands hinweg entwertet die Konsistenz, die das Feature erst wertvoll macht.
- Es passt außerdem zur bestehenden Regel **„Referenzen nie 1:1 kopieren"**.
Der Kopierer sieht die fremden Modelle nur als **Beschriftung** („Modell: kleine Kerze"), um zu verstehen, was das Rezept braucht nicht als nutzbares Asset.
---
## 4. Nischen und Slot-Kompatibilität
Ein Autohaus will keine Werbung im Stil eines Kosmetikstudios. Deshalb sind Posts und Modelle zweifach kategorisiert:
- **Nische** feste Branchenliste (Kosmetik, Auto, Gastro, Fitness, Mode, …), am Post und am Modell. Filtert den Feed.
- **Modell-Typ-Tags** was das Modell konkret zeigt (Fahrzeug, Getränk, Tube, Person weiblich, Innenraum, …).
**Kompatibilitätsregel beim Kopieren:** Ein Slot akzeptiert nur Modelle mit passendem Typ-Tag. Ein Post, dessen Produkt-Slot ein Fahrzeug enthält, nimmt kein Lippenstift-Modell an die Komposition (Größenverhältnis, Handhaltung, Perspektive) würde nicht funktionieren.
**Enum-Zwang.** Nischen und Typ-Tags sind feste Listen, keine freie KI-Vergabe. Das folgt derselben Logik wie beim Video-Tagger (`projekt-uebersicht.md` §8: Enum-Zwang ist „the highest-leverage decision", doppelte Absicherung durch Prompt-Regel **und** Code-Validator). Ohne feste Liste driften die Kategorien und die Kompatibilitätsprüfung wird wertlos.
---
## 5. Die Bilderkette
Ein Post besteht aus mehreren Bildern (Karussell). Über die Kette hinweg gilt:
- **Was variiert:** Position und Winkel des Motivs
- **Was konstant bleibt:** Modelle, Kulisse, Licht, Farbe, Werbetext
Das **Text-Overlay ist ein eigenes Bild** in der Kette nicht eine Textebene auf einem Motivbild. Das hat einen praktischen Vorteil: Der Text lässt sich austauschen, ohne die Motive neu zu generieren.
Format gilt für die gesamte Kette, nicht pro Bild.
---
## 6. Post erstellen Ablauf
```
Modell(e) vorhanden?
nein → Modell anlegen (Person / Produkt / Kulisse)
ja ↓
Ordner wählen ← bestimmt, welches Wissen injiziert wird (§9)
Format wählen (1:1 / 4:5 / 9:16)
Slots füllen (Kulisse, Modelle, Licht/Kamera/Farbe) + Werbetext
Generierung (ein festes Bildmodell, keine Auswahlrunde)
Bilderkette prüfen
privat behalten ODER aktiv veröffentlichen → Feed
```
**Modell zuerst**, wenn noch keins existiert. Wer schon Modelle hat, steigt direkt beim Ordner ein.
**Veröffentlichen ist aktiv.** Standard ist privat. Nur wer bewusst „Teilen" drückt, landet im Feed. Das ist bei einer offenen Nutzerbasis (Creator und Privatnutzer, nicht nur zahlende Brands) auch datenschutzrechtlich die einzig vertretbare Voreinstellung.
---
## 7. Kopieren
Der Kern des Feeds. Ablauf:
1. Nutzer sieht einen Post und drückt **Kopieren**.
2. Das System analysiert den zugrundeliegenden Prompt und stellt ihn **vereinfacht als befüllte Chips** dar: `Kulisse: Badezimmer, Morgenlicht` · `Modell: kleine Kerze` · `Licht: weich von der Seite` · `Kamera: Nahaufnahme, leicht von oben` · `Format: 4:5`.
3. Der Nutzer ersetzt die **Modell-Chips durch eigene** (Pflicht, §3). Kulisse, Licht, Kamera, Farbe kann er übernehmen oder ändern.
4. Der **Werbetext wird von der KI neu geschrieben** im Stil des Originals, aber auf Basis des eigenen Produkts. Der fremde Text wird nie übernommen; fremde Claims und Markennennungen kämen sonst ungeprüft in den eigenen Post.
5. Textliches Nachschärfen ist jederzeit möglich.
6. Generierung wie in §6.
**Was der Kopierer nie sieht:** den Original-Prompt im Wortlaut, die Attribut-Scores des Erstellers, dessen Regeln oder Dev-Modus-Inhalte. Die vereinfachte Slot-Darstellung ist eine **Abstraktion, keine Offenlegung** damit bleibt das Betriebsgeheimnis aus `projekt-uebersicht.md` §4/§9 gewahrt.
**Kopieren erzeugt ein Signal:** Jede Kopie zahlt auf den Feed-Score des Originals ein (§8).
---
## 8. Feed-Score
> **Wichtig begriffliche Abgrenzung:** Der Feed-Score ist **kein Elo**. Elo setzt Duelle voraus: zwei Kandidaten treten an, einer gewinnt, Punkte wandern vom Verlierer zum Gewinner. Im Feed duelliert nichts Kopien, Likes und Reichweite sind aufsummierte Zähler. Beide Systeme „Elo" zu nennen rächt sich spätestens im Code. **Elo = brand-interne Attribute. Feed-Score = öffentliches Feed-Ranking.**
### Was gerankt wird
**Post und Modell getrennt.** Ein Post hat einen eigenen Score. Jedes darin verwendete Modell sammelt zusätzlich Punkte über alle seine Posts hinweg. Damit lassen sich Top-Posts **und** Top-Modelle browsen „ich suche eine gute kleine Kerze" wird zu einer eigenen Sucherfahrung.
### Signale, gewichtet
| Quelle | Signal | Gewicht |
|---|---|---|
| In-App | Views | ×0,1 |
| In-App | Likes | ×0,5 |
| In-App | **Kopien** | ×1 |
| Verbundene Social-Accounts | echte Reichweite des veröffentlichten Posts | ×2 |
Das **Prinzip** entspricht der Gewichtung im Elo-System (`projekt-uebersicht.md` §5): schwache Signale sind sofort verfügbar, starke überschreiben sie später. Die **Zahlen sind bewusst andere** ×0,1 und ×0,5 existieren im Elo-System nicht, weil es dort keine Views und Likes gibt. In-App-Signale starten das Ranking; verbundene Accounts korrigieren es mit echter Reichweite.
**Kopien wiegen mehr als Likes** ein Like ist eine Meinung, eine Kopie ist eine Handlung mit Aufwand.
### Drei Korrekturen, ohne die das Ranking kippt
1. **Zeit-Decay.** Ohne Verfall gewinnt immer das Älteste, weil es am längsten sammeln konnte. Der Score verfällt exponentiell (Halbwertszeit als Parameter, Startwert 30 Tage).
2. **Explorations-Slot.** Ein fester Anteil der Feed-Positionen ist für neue Posts reserviert. Sonst bekommt nur, wer oben steht, Views und wer Views bekommt, bleibt oben. Neue Posts kämen nie hoch.
3. **Manipulationsschutz.** Globale Sichtbarkeit erzeugt Anreiz für Fake-Signale. Mindestens: Rate-Limits pro Konto, Signale nur von verifizierten Konten voll gewichtet, Ausreißer-Erkennung. Bei den brand-internen Scores war das kein Thema hier ist es eins.
---
## 9. Ordner
Das ist der wichtigste neue Baustein und die Antwort auf ein Problem, das der Feed selbst erzeugt.
### Das Problem
Wenn alle die Top-Posts kopieren, wandern dieselben Attribute in alle Knowledge Bases. Alle Ergebnisse konvergieren, jede Werbung sieht gleich aus und der Moat („dein Wissen ist einzigartig") wird wertlos.
### Die Lösung: Wissen segmentieren statt mitteln
Ein Ordner ist ein **Wissens-Scope**. Attribut-Scores werden **je Attribut je Ordner** geführt. Beim Generieren zieht der Knowledge-Selector nicht mehr die brand-weiten Top-Attribute, sondern **nur die des gewählten Ordners**. Der neue Post trägt damit den Vibe der Sammlung nicht den Durchschnitt des Feeds.
„Warmes Abendlicht" kann im Ordner *Sommerkampagne* auf 6800 stehen und im Ordner *Dark Studio* gar nicht existieren. Statt eines Durchschnitts entstehen mehrere konkurrierende Wissensinseln nebeneinander.
### Eigenschaften
| | |
|---|---|
| **Sichtbarkeit** | privat pro Brand Ordner sind kein soziales Objekt |
| **Inhalt** | eigene **und** fremde Posts aus dem Feed (wie ein Moodboard) |
| **Mehrfachablage** | ein Post darf in mehreren Ordnern liegen |
| **Wahl** | **vor** der Generierung nur so kann das Theme-Wissen in den Prompt |
| **Einsortierung** | KI schlägt beim Speichern einen bestehenden Ordner vor oder bietet an, einen neuen mit passendem Theme anzulegen |
**Fremde Posts zählen voll.** Wissen entsteht damit durch *Sammeln*, nicht erst durch Ausgeben der Ordner ist sofort nach dem Anlegen nützlich. Risiko, bewusst in Kauf genommen: Ein Ordner kann Attribute hoch bewerten, die für die eigene Zielgruppe nie getestet wurden. Die Korrektur passiert über eigene Ergebnisse mit der Zeit.
### Zwei Schalter beim Anlegen
**1. Zweck**
| Wert | Bedeutung |
|---|---|
| `sammlung` | reines Kategorisieren. Kein Score, kein Einfluss auf Generierung. |
| `wissens_scope` | aus dem Ordner heraus wird erstellt. Attribut-Scores werden geführt. |
**2. Startwerte** (nur bei `wissens_scope`)
| Wert | Bedeutung | Wann sinnvoll |
|---|---|---|
| `erben` | Ordner startet als Kopie der brand-weiten Scores und driftet mit jedem Signal davon weg | Der Ordner verfeinert die bestehende Ästhetik z. B. „Sommerkampagne" innerhalb derselben Bildsprache |
| `aus_posts` | Nur was in den gesammelten Posts steckt, existiert im Ordner. Startwert = Kategorie-Ø **innerhalb des Ordners** | Der Ordner soll bewusst **anders** sein als das Bestehende |
> **Warum die Wahl wichtig ist:** Bei einem Ordner, der bewusst gegen die eigene Historie steht, arbeitet `erben` aktiv gegen den Zweck. Beispiel: brand-weit steht `warmes-abendlicht` auf 6800 und `hartes-direktlicht` auf 3100. Ein neuer Ordner „Dark Studio" mit vier gesammelten Posts würde bei `erben` trotzdem warmes Abendlicht injizieren es bräuchte rund 15 Signale, um 6800 zu überholen. Bei `aus_posts` existiert warmes Abendlicht im Ordner gar nicht und der Vibe stimmt ab dem ersten Post.
### Ordner-Kaltstart
Ein frisch angelegter Ordner ist **sofort nutzbar** keine Mindestanzahl an Posts. Ein Ordner entsteht genau in dem Moment, in dem jemand etwas Bestimmtes will; ihn dann zu sperren, bestraft die Absicht.
Die statistische Schwäche (bei vier Posts ist die *Rangfolge* innerhalb des Ordners kaum belastbar, auch wenn die *Richtung* stimmt) wird über den **K-Faktor** aufgefangen, nicht über eine Sperre: K bleibt hoch (32), solange der Ordner dünn ist, und sinkt Richtung 8, wenn genug Signale da sind. Das ist exakt die Regel aus `projekt-uebersicht.md` §5 „Unsicherheit steckt im K-Faktor" nur auf Ordner-Ebene angewandt.
Die UI zeigt den Reifegrad offen an: *„4 Posts dieser Ordner lernt noch."*
### Zwei Wissensebenen
Die brand-weite Ebene bleibt bestehen und dient als Basis für Generierung **ohne** Ordner. Wird ein Ordner gewählt, **überschreibt** er sie vollständig es wird nicht gemischt. Mischen würde genau die Abgrenzung verwässern, für die der Ordner existiert.
---
## 10. Kopplung Feed ↔ Knowledge Base
**Gewählt: Import + Rückfluss.**
1. **Import beim Kopieren.** Die Slots des kopierten Posts werden sofort als Attribute in der eigenen KB angelegt Startwert nach Ordner-Regel (§9). Die KB ist damit nicht mehr leer, bevor der erste Euro ausgegeben wurde. Das ist die direkte Antwort auf das Kaltstart-Risiko aus `projekt-uebersicht.md` §11.
2. **Rückfluss nach Veröffentlichung.** Läuft ein Post gut, steigen die beteiligten Attribute in der eigenen KB **gewichtet ×0,3**. Bewusst niedrig: Reichweite kann am Posting-Zeitpunkt, an Hashtags oder am Zufall liegen, nicht am Licht. Dieselbe Skepsis wie bei einer bestätigten Favoriten-Wahl (§5 der Übersicht).
Die Konvergenz-Gefahr, die der Import mit sich bringt, wird durch die Ordner-Segmentierung (§9) aufgefangen: Importiertes Wissen landet im Scope des jeweiligen Ordners, nicht in einem gemeinsamen Topf.
> **Zu prüfen:** Diese Kopplung ist im Gespräch als „gut" bewertet, aber nicht ausdrücklich gegen die Alternativen (nur Rückfluss / strikt getrennt) final bestätigt worden. Vor der Umsetzung noch einmal explizit entscheiden.
---
## 11. Die zwei Systeme im Überblick
| | **Elo (Knowledge Base)** | **Feed-Score** |
|---|---|---|
| Rankt | Attribute (licht, kamera, location …) | Posts und Modelle |
| Scope | pro Brand, jetzt zusätzlich **pro Ordner** | global über alle Nutzer, gefiltert nach Nische |
| Sichtbar für Nutzer | nein | ja das ist der Zweck |
| Mathematik | echtes Elo (Paarvergleich, K-Faktor, Skala 010.000) | gewichtete Summe + Zeit-Decay |
| Signale | Vote, gezielte Frage, Ad-Performance, LLM-Vergleich | Views, Likes, Kopien, Social-Reichweite |
| Manipulationsrisiko | gering (intern) | hoch (global sichtbar) |
| Zweck | Prompts anreichern | Feed sortieren |
---
## 12. Rechtliches was neu dazukommt
Ergänzend zu `projekt-uebersicht.md` §10:
- **Öffentlicher Feed = nutzergenerierte Inhalte.** Damit werden Melde- und Moderationspflichten relevant (DSA), die beim geschlossenen B2B-Produkt nicht galten.
- **Kennzeichnung KI-generierter Inhalte** (EU AI Act) gilt für Bilder genauso wie für Videos im Feed sichtbar, nicht nur beim Export.
- **Modelle werden nie geteilt** (§3) das entschärft Marken-, Design- und Persönlichkeitsrecht an der Wurzel, statt es über AGB weiterzureichen.
- **Breitere Nutzerbasis** (Creator, Privatnutzer) heißt: Minderjährigenschutz und Altersgrenzen müssen geklärt werden. Beim reinen B2B-Produkt war das kein Thema.
- **Social-Account-Anbindung** für organische Reichweite braucht eigene App-Reviews bei Meta/TikTok zusätzlich zu den Ads-Berechtigungen. Laut Übersicht §10 früh beantragen, die Verfahren dauern.
---
## 13. Was sich in der bestehenden Doku ändert
| Datei | Änderung |
|---|---|
| `datenbank-aufbau.md` | `attributes` wird aufgetrennt (Scores wandern nach `attribute_scores`, je Ordner) · neue Tabellen `folders`, `posts`, `post_images`, `post_folders`, `post_metrics` · `assets.typ` um `kulisse`, neue Spalte `assets.typ_tags` · `score_events.folder_id` · Nischen-Felder · `prompt_templates.key` bis P22 · neue Functions · Bucket `generated-images` |
| `prompt-inventar.md` | vier neue Denk-Punkte P19P22 |
| `projekt-uebersicht.md` | neues Kapitel, aktualisierte Risiken und Roadmap |
| `fragenkatalog-onboarding.md` | Nische als Pflichtfrage |
| `prototyp-app.html` | **noch nicht angefasst** Feed, Post-Detail, Kopier-Screen, Ordner-Ansicht fehlen |
---
## 14. Offene Punkte
| # | Punkt | Warum es zählt |
|---|---|---|
| 1 | **Welches Bildmodell** | Multi-Referenz-Komposition (Person + Produkt + Kulisse konsistent) ist das entscheidende Auswahlkriterium, nicht die reine Bildqualität |
| 2 | **Preismodell** | €150300/Brand/Monat passt nicht zu Creator- und Privatnutzern. Der Bild-Modus braucht eine eigene, deutlich niedrigere Stufe oder ein Freemium-Modell mit Kontingent |
| 3 | **Nischen-Liste final** | Welche Branchen, wie granular, und was passiert bei Nutzern, die in keine passen |
| 4 | **Modell-Typ-Tags final** | Die Enum-Liste, gegen die die Slot-Kompatibilität geprüft wird |
| 5 | **Feed-Score-Parameter** | Halbwertszeit des Decay, Größe des Explorations-Slots, konkrete Gewichte |
| 6 | **Moderation** | Wer prüft veröffentlichte Posts, und wann vorab oder auf Meldung |
| 7 | **Kopplung final bestätigen** | siehe §10 |
| 8 | **Prototyp** | Die neuen Screens sind noch nicht klickbar |

Some files were not shown because too many files have changed in this diff Show More