feat: Video unter dem Headerbild aus dem Medienverzeichnis (intro.mp4)
Startet nur nach Klick; mit Vorschaubild (intro.jpg) lädt vorab nichts vom Video. Vorschaubilder von Intro und Reels laufen über die Bildoptimierung statt im Original, Videos bevorzugen eine Web-Fassung <name>.web.mp4. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
51b6d79bbf
commit
33f5096bf1
6 files changed
+99
-10
No files matched your search
@@ -22,6 +22,14 @@ Das Logo wird 32 bis 40 px hoch angezeigt. Ein querformatiges Logo ohne Rand wir
|
|||||||
| `header.jpg` (oder `.webp`, `.png`) | Bild über die ganze Breite oben | Querformat, mindestens 2400 px breit. Ohne Datei erscheint die Luftaufnahme der Villa |
|
| `header.jpg` (oder `.webp`, `.png`) | Bild über die ganze Breite oben | Querformat, mindestens 2400 px breit. Ohne Datei erscheint die Luftaufnahme der Villa |
|
||||||
| `header.mp4` (optional) | Video statt Standbild, läuft stumm in Schleife | Kurz halten (unter 15 s, unter 10 MB). `header.jpg` dient als Vorschaubild |
|
| `header.mp4` (optional) | Video statt Standbild, läuft stumm in Schleife | Kurz halten (unter 15 s, unter 10 MB). `header.jpg` dient als Vorschaubild |
|
||||||
|
|
||||||
|
## Video unter dem Headerbild
|
||||||
|
|
||||||
|
| Datei | Wofür | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| `intro.mp4` | Video direkt unter dem Headerbild, startet erst nach Klick | Quer- oder Hochformat. Ohne Datei entfällt der Abschnitt |
|
||||||
|
| `intro.jpg` (oder `.webp`, `.png`) | Vorschaubild des Videos | Empfohlen: Mit Vorschaubild lädt vom Video vorab nichts |
|
||||||
|
| `intro.web.mp4` (optional) | Web-Fassung, wird bevorzugt angezeigt | Erzeugen mit `pnpm media:optimize`, siehe unten |
|
||||||
|
|
||||||
## Team (Ordner `team/`)
|
## Team (Ordner `team/`)
|
||||||
|
|
||||||
Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Das Bild wird oben rund (als Bogen) beschnitten.
|
Hochformat 3:4 oder 4:5, mindestens 1200 px breit, Gesicht im oberen Drittel. Das Bild wird oben rund (als Bogen) beschnitten.
|
||||||
|
|||||||
+3
-1
@@ -6,6 +6,7 @@ import { AccommodationSection } from "@/components/sections/CampSections";
|
|||||||
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
import { ExpectSection, ScheduleSection } from "@/components/sections/ExpectSections";
|
||||||
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
import { ClosingSection, FaqSection } from "@/components/sections/FaqSection";
|
||||||
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
import { AboutSection, ReelsSection, TeamSection } from "@/components/sections/PeopleSections";
|
||||||
|
import { IntroVideoSection } from "@/components/sections/IntroVideoSection";
|
||||||
import { PricingSection } from "@/components/sections/PricingSection";
|
import { PricingSection } from "@/components/sections/PricingSection";
|
||||||
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
import { ReviewsSection } from "@/components/sections/ReviewsSection";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
@@ -16,7 +17,7 @@ import { loadPublicPage } from "@/lib/public-page";
|
|||||||
import { loadSiteMedia } from "@/lib/site-media";
|
import { loadSiteMedia } from "@/lib/site-media";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Gliederung nach der Filmcamp-Referenz: Headerbild, Termin, was dich
|
* Gliederung nach der Filmcamp-Referenz: Headerbild, Video, Termin, was dich
|
||||||
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
|
* erwartet (die Villa), Tagesablauf, Unterkunft, Preis, Team, Reels,
|
||||||
* Fragen, Abschluss.
|
* Fragen, Abschluss.
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +38,7 @@ export default async function HomePage() {
|
|||||||
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
|
<SiteHeader logoLight={media.logoLight} logoDark={media.logoDark} />
|
||||||
<main id="inhalt">
|
<main id="inhalt">
|
||||||
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
|
<Hero pricing={pricing} media={media.header} video={media.headerVideo} />
|
||||||
|
<IntroVideoSection video={media.intro} showDrafts={showDrafts} />
|
||||||
<DateBar facts={facts} />
|
<DateBar facts={facts} />
|
||||||
<ExpectSection />
|
<ExpectSection />
|
||||||
<ScheduleSection facts={facts} />
|
<ScheduleSection facts={facts} />
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import type { Media } from "@/content/site";
|
import type { Media } from "@/content/site";
|
||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
import { MediaFrame } from "./MediaFrame";
|
import { MediaFrame } from "./MediaFrame";
|
||||||
|
|
||||||
|
/** Reels sind hoch und hoechstens ein Drittel breit. */
|
||||||
|
const POSTER_WIDTH = 1080;
|
||||||
|
|
||||||
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
type Slot = { name: string | null; caption: string | null; poster: Media | null; src: string | null };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
|
* Ein Video aus dem eigenen Medienverzeichnis, im eigenen Seitenverhaeltnis
|
||||||
* (quer oder hoch). Es startet nie von selbst; geladen werden vorab nur die
|
* (quer oder hoch). Es startet nie von selbst. Mit Vorschaubild laedt vorab
|
||||||
* Metadaten und das erste Bild als Vorschau (#t=0.1), falls kein
|
* nichts vom Video, ohne nur Metadaten und erstes Bild (#t=0.1). Kein
|
||||||
* Vorschaubild hinterlegt ist. Kein externer Anbieter.
|
* externer Anbieter.
|
||||||
*/
|
*/
|
||||||
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: number; showDrafts: boolean }) {
|
||||||
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
|
const label = slot.name ? `Video von ${slot.name}` : `Video ${index + 1}`;
|
||||||
const poster = slot.poster?.kind === "image" ? slot.poster.src : undefined;
|
// Vorschaubild ueber die Bildoptimierung statt im Original (ein Reel-PNG
|
||||||
|
// hatte 1,3 MB); mit Vorschaubild laedt vom Video vorab nichts.
|
||||||
|
const poster = slot.poster?.kind === "image" ? optimizedImageUrl(slot.poster.src, POSTER_WIDTH) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<figure>
|
<figure>
|
||||||
@@ -21,7 +27,7 @@ export function VideoSlot({ slot, index, showDrafts }: { slot: Slot; index: numb
|
|||||||
poster={poster}
|
poster={poster}
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload={poster ? "none" : "metadata"}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
|
className="mx-auto block max-h-[80vh] w-full rounded-2xl bg-ink object-contain"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { optimizedImageUrl } from "@/lib/image-url";
|
||||||
|
import type { IntroVideo } from "@/lib/site-media";
|
||||||
|
|
||||||
|
/** Breite des Vorschaubilds: scharf bis zur vollen Inhaltsbreite. */
|
||||||
|
const POSTER_WIDTH = 1920;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video direkt unter dem Headerbild (intro.mp4 im Medienverzeichnis,
|
||||||
|
* Vorschaubild intro.jpg, docs/MEDIEN.md). Startet nur nach Klick. Mit
|
||||||
|
* Vorschaubild laedt der Browser vorher gar nichts vom Video
|
||||||
|
* (preload="none"); ohne wird nur das erste Bild als Vorschau geholt.
|
||||||
|
*/
|
||||||
|
export function IntroVideoSection({ video, showDrafts }: { video: IntroVideo; showDrafts: boolean }) {
|
||||||
|
if (!video.src) {
|
||||||
|
if (!showDrafts) return null;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 text-paper/70 md:py-16">
|
||||||
|
<p className="container-edge text-center">
|
||||||
|
<span className="draft-flag">Platzhalter:</span> Hier erscheint das Video, sobald <code>intro.mp4</code> im Medienverzeichnis liegt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = video.poster?.kind === "image" ? optimizedImageUrl(video.poster.src, POSTER_WIDTH) : undefined;
|
||||||
|
return (
|
||||||
|
<section aria-label="Video" className="bg-ink py-12 md:py-20">
|
||||||
|
<div className="container-edge">
|
||||||
|
<video
|
||||||
|
src={poster ? video.src : `${video.src}#t=0.1`}
|
||||||
|
poster={poster}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload={poster ? "none" : "metadata"}
|
||||||
|
aria-label="Video ARTISTA Modelcamp"
|
||||||
|
// Eigenes Seitenverhaeltnis (quer oder hoch), wie bei den Reels.
|
||||||
|
className="mx-auto block max-h-[85vh] w-full rounded-2xl bg-black object-contain"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* URL eines Bildes ueber die Next.js-Bildoptimierung (AVIF/WebP, passende
|
||||||
|
* Breite) fuer Stellen, an denen kein <Image> moeglich ist, etwa das
|
||||||
|
* poster-Attribut von <video>. Ein 15-MB-Foto aus dem Medienverzeichnis wird
|
||||||
|
* so als wenige Dutzend Kilobyte ausgeliefert statt im Original.
|
||||||
|
*/
|
||||||
|
import { getImageProps } from "next/image";
|
||||||
|
|
||||||
|
/** Liefert eine Variante mit mindestens `width` Pixeln Breite. */
|
||||||
|
export function optimizedImageUrl(src: string, width: number): string {
|
||||||
|
// getImageProps gibt als src die 2x-Variante der angegebenen Breite
|
||||||
|
// zurueck; mit halber Breite entspricht sie der gewuenschten.
|
||||||
|
const half = Math.ceil(width / 2);
|
||||||
|
return getImageProps({ src, alt: "", width: half, height: half, quality: 75 }).props.src;
|
||||||
|
}
|
||||||
+18
-4
@@ -52,12 +52,21 @@ async function find(base: string, exts: readonly string[]): Promise<Found | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video mit Vorrang fuer die Web-Fassung "<name>.web.mp4" (1080p,
|
||||||
|
* faststart, siehe scripts/optimize-media.ts). Fehlt sie, das Original.
|
||||||
|
*/
|
||||||
|
async function video(base: string): Promise<Found | null> {
|
||||||
|
return (await find(`${base}.web`, VIDEO_EXTS)) ?? find(base, VIDEO_EXTS);
|
||||||
|
}
|
||||||
|
|
||||||
async function image(base: string, alt: string): Promise<Media | null> {
|
async function image(base: string, alt: string): Promise<Media | null> {
|
||||||
const found = await find(base, IMAGE_EXTS);
|
const found = await find(base, IMAGE_EXTS);
|
||||||
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
|
return found ? { kind: "image", src: found.src, alt, width: 1200, height: 1500 } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Reel = { src: string | null; poster: Media | null };
|
export type Reel = { src: string | null; poster: Media | null };
|
||||||
|
export type IntroVideo = { src: string | null; poster: Media | null };
|
||||||
|
|
||||||
export type SiteMedia = {
|
export type SiteMedia = {
|
||||||
/** Logo fuer helle Flaechen (dunkles Logo). */
|
/** Logo fuer helle Flaechen (dunkles Logo). */
|
||||||
@@ -66,22 +75,26 @@ export type SiteMedia = {
|
|||||||
logoLight: string | null;
|
logoLight: string | null;
|
||||||
header: Media;
|
header: Media;
|
||||||
headerVideo: string | null;
|
headerVideo: string | null;
|
||||||
|
/** Video direkt unter dem Headerbild (intro.mp4, Vorschaubild intro.jpg). */
|
||||||
|
intro: IntroVideo;
|
||||||
portraits: Record<string, Media>;
|
portraits: Record<string, Media>;
|
||||||
reels: Reel[];
|
reels: Reel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loadSiteMedia(): Promise<SiteMedia> {
|
export async function loadSiteMedia(): Promise<SiteMedia> {
|
||||||
const people = [site.team.head, ...site.team.people];
|
const people = [site.team.head, ...site.team.people];
|
||||||
const [logoDark, logoLight, header, headerVideo, portraitList, reels] = await Promise.all([
|
const [logoDark, logoLight, header, headerVideo, introVideo, introPoster, portraitList, reels] = await Promise.all([
|
||||||
find("logo", LOGO_EXTS),
|
find("logo", LOGO_EXTS),
|
||||||
find("logo-hell", LOGO_EXTS),
|
find("logo-hell", LOGO_EXTS),
|
||||||
image("header", "ARTISTA Modelcamp in der Toskana"),
|
image("header", "ARTISTA Modelcamp in der Toskana"),
|
||||||
find("header", VIDEO_EXTS),
|
video("header"),
|
||||||
|
video("intro"),
|
||||||
|
image("intro", "Vorschaubild Video ARTISTA Modelcamp"),
|
||||||
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
|
Promise.all(people.map((p) => image(`team/${p.slug}`, `Porträt ${p.name}`))),
|
||||||
Promise.all(
|
Promise.all(
|
||||||
[1, 2, 3].map(async (n): Promise<Reel> => {
|
[1, 2, 3].map(async (n): Promise<Reel> => {
|
||||||
const [video, poster] = await Promise.all([find(`reels/reel-${n}`, VIDEO_EXTS), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
|
const [clip, poster] = await Promise.all([video(`reels/reel-${n}`), image(`reels/reel-${n}`, `Vorschaubild Reel ${n}`)]);
|
||||||
return { src: video?.src ?? null, poster };
|
return { src: clip?.src ?? null, poster };
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -96,6 +109,7 @@ export async function loadSiteMedia(): Promise<SiteMedia> {
|
|||||||
logoLight: logoLight?.src ?? logoDark?.src ?? null,
|
logoLight: logoLight?.src ?? logoDark?.src ?? null,
|
||||||
header: header ?? site.hero.media,
|
header: header ?? site.hero.media,
|
||||||
headerVideo: headerVideo?.src ?? null,
|
headerVideo: headerVideo?.src ?? null,
|
||||||
|
intro: { src: introVideo?.src ?? null, poster: introPoster },
|
||||||
portraits,
|
portraits,
|
||||||
reels,
|
reels,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in new issue
Block a user