Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hero-dual-quality.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream-website": minor
---

Ship the hero film in two qualities: the page serves a high-res render by default and swaps to a low variant for data-saver clients and small screens (navigator.connection.saveData / effectiveType / viewport + DPR pick). build-media.mjs now emits both variants and records both sources in hero.meta.json.
Binary file added website/site/public/media/hero-low.mp4
Binary file not shown.
51 changes: 39 additions & 12 deletions website/site/scripts/build-media.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,38 +45,65 @@ const landingVideo = [
path.join(LANDING, 'nostream-hero-low.mp4'),
].find((p) => p && existsSync(p))

if (landingVideo) {
const filmMeta = readFilmMeta(landingVideo)
if (filmMeta?.nipCount == null) {
if (existsSync(HERO_META)) unlinkSync(HERO_META)
console.error(
`build-media: ${landingVideo} has no film.meta.json beside it — re-render in website/hero-animation (pnpm run render) or pick a source that includes meta.`,
)
process.exit(1)
}
// The low variant ships beside the full one for mobile / Save-Data. It is the
// previous site video when a fresh high-res render replaces hero.mp4, or the
// low render when that is all hero-animation has produced.
const landingLowVideo = [
process.env.HERO_LOW_SRC,
path.join(LANDING, 'nostream-hero-low.mp4'),
landingVideo === path.join(LANDING, 'nostream-hero-low.mp4') ? null : landingVideo,
].find((p) => p && existsSync(p) && p !== landingVideo)

const encodeHero = (source, outPath, crf) =>
execFileSync(
'ffmpeg',
[
'-y', '-loglevel', 'error',
'-i', landingVideo,
'-i', source,
'-vf', 'unsharp=5:5:0.85:5:5:0.0',
'-an', '-c:v', 'libx264', '-preset', 'slow', '-pix_fmt', 'yuv420p', '-movflags', '+faststart',
'-tune', 'animation',
'-crf', '15',
path.join(MEDIA, 'hero.mp4'),
'-crf', String(crf),
outPath,
],
{ stdio: 'inherit' },
)

if (landingVideo) {
const filmMeta = readFilmMeta(landingVideo)
if (filmMeta?.nipCount == null) {
if (existsSync(HERO_META)) unlinkSync(HERO_META)
console.error(
`build-media: ${landingVideo} has no film.meta.json beside it — re-render in website/hero-animation (pnpm run render) or pick a source that includes meta.`,
)
process.exit(1)
}

encodeHero(landingVideo, path.join(MEDIA, 'hero.mp4'), 15)
console.log('hero.mp4 ← landing film (film.ts)')

if (landingLowVideo) {
const lowMeta = readFilmMeta(landingLowVideo)
if (lowMeta?.nipCount === filmMeta.nipCount) {
encodeHero(landingLowVideo, path.join(MEDIA, 'hero-low.mp4'), 22)
Comment on lines +86 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old renders pass the check

lowMeta and filmMeta read the same output/film.meta.json for the default full and low renders. render.ts overwrites that file after either render. If an old low render remains when a new full render finishes, this comparison always passes and ships the old film alongside the new one. Write and check metadata tied to each video before accepting the pair.

console.log('hero-low.mp4 ← landing film (low variant)')
} else {
console.warn(
`skip hero-low.mp4: low source meta (${lowMeta?.nipCount}) disagrees with full source (${filmMeta.nipCount}) — regenerate both renders together`,
)
}
} else {
console.warn('skip hero-low.mp4: no second source (run `pnpm run render:small` in website/hero-animation)')
}

writeFileSync(
HERO_META,
`${JSON.stringify(
{
nipCount: filmMeta.nipCount,
generatedAt: filmMeta.generatedAt,
sourceVideo: path.relative(HERO_DIR, landingVideo),
...(landingLowVideo ? { sourceLowVideo: path.relative(HERO_DIR, landingLowVideo) } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Metadata names a rejected source

sourceLowVideo is recorded whenever a candidate exists, even when its metadata disagrees and the script skips encoding it. With a mismatched HERO_LOW_SRC, hero.meta.json therefore names a rejected source rather than the source of the served file. Record this field only after the low video is successfully encoded.

},
null,
2,
Expand Down
5 changes: 3 additions & 2 deletions website/site/src/components/FilmFrame.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ import FilmScreen from './FilmScreen.astro'

interface Props {
src: string
lowSrc?: string
poster: string
title: string
class?: string
}
const { src, poster, title, class: className } = Astro.props
const { src, lowSrc, poster, title, class: className } = Astro.props
---

<div class:list={['film-frame', className]}>
<FilmScreen src={src} poster={poster} title={title} class="film-crisp" />
<FilmScreen src={src} lowSrc={lowSrc} poster={poster} title={title} class="film-crisp" />
</div>

<style>
Expand Down
33 changes: 31 additions & 2 deletions website/site/src/components/FilmScreen.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@ import ScreenBar from './ScreenBar.astro'

interface Props {
src: string
lowSrc?: string
poster: string
title: string
class?: string
}
const { src, poster, title, class: className } = Astro.props
const { src, lowSrc, poster, title, class: className } = Astro.props
---

<figure class:list={['screen', className]}>
<ScreenBar title={title} />
<video muted loop playsinline preload="auto" poster={poster} data-autoplay aria-label={title}>
<video muted loop playsinline preload="auto" poster={poster} data-autoplay data-low-src={lowSrc} aria-label={title}>
<source src={src} type="video/mp4" />
Comment on lines +16 to 17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Full video downloads before selection

The full video is already discoverable with preload="auto" before the quality picker runs. Astro processes this ordinary <script> as a module, so it does not block parsing before the browser starts loading the full source. Data-saver and small-screen visitors can download part of the full video and then start another download for the low video. Keep the full URL out of the active source until the choice is made, with a separate fallback for visitors without JavaScript.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

</video>
</figure>
Expand All @@ -22,3 +23,31 @@ const { src, poster, title, class: className } = Astro.props
margin: 0;
}
</style>

<script>
// Quality pick: swap in the low variant for data-saver clients and small
// screens before the browser commits to the full download. The full source
// stays the default (the <source> above), so the swap only ever goes down.
const setup = (video: HTMLVideoElement) => {
const lowSrc = video.dataset.lowSrc
if (!lowSrc) return
const wantsLow =
navigator.connection?.saveData === true ||
(navigator.connection?.effectiveType ?? '4g') in { 'slow-2g': 1, '2g': 1, '3g': 1 } ||
Comment on lines +35 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Browser checks add type errors

navigator.connection is not declared by the standard TypeScript DOM types, and the site has no declaration that adds it. Optional chaining makes unsupported browsers safe, but does not fix the type error. These two accesses add errors to the site's check command. Add a typed optional connection property and use it for both checks.

(matchMedia('(max-width: 768px)').matches && window.devicePixelRatio <= 2)
if (!wantsLow) return
const source = video.querySelector('source')
if (!source) return
const time = video.currentTime
const wasPlaying = !video.paused
source.src = lowSrc
video.load()
video.currentTime = time
if (wasPlaying) void video.play()
delete video.dataset.lowSrc
}
document.querySelectorAll<HTMLVideoElement>('video[data-low-src]').forEach((video) => {
if (video.dataset.autoplay != null) setup(video)
else video.addEventListener('play', () => setup(video), { once: true })
})
</script>
1 change: 1 addition & 0 deletions website/site/src/components/home/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { VERSION } from '../../data/site'
<FilmFrame
class="hero-film"
src="/media/hero.mp4"
lowSrc="/media/hero-low.mp4"
poster="/media/hero-poster.webp"
title={`/hero · nostream ${VERSION}`}
/>
Expand Down
Loading