Skip to content
Merged
83 changes: 83 additions & 0 deletions apps/website/scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Website Scripts

## `refresh-ashby-snapshot.ts`

Pulls the latest job postings from Ashby and writes
`src/data/ashby-roles.snapshot.json`. Invoked by the `Release: Website`
GitHub Actions workflow; also runnable locally via
`pnpm --filter @comfyorg/website ashby:refresh-snapshot`.

## `process-videos.sh`

Generates multi-resolution VP9/WebM + H.264/MP4 variants and a poster
frame for marketing videos using `ffmpeg`. Run **locally** before
uploading the outputs to `media.comfy.org`; this is not wired into CI.

```sh
apps/website/scripts/process-videos.sh \
./video-sources \
./dist/videos \
"640 960 1280 1920"
```

### Output

For each source video at `./video-sources/foo.mp4`, you get:

```text
foo-640.webm foo-640.mp4
foo-960.webm foo-960.mp4
foo-1280.webm foo-1280.mp4
foo-1920.webm foo-1920.mp4
foo-poster.jpg
```

The naming convention is enforced by `buildVideoSources()` in
`src/utils/video.ts`, which the `<SiteVideo>` Vue component uses to
emit `<source>` URLs.

### Pairing with `<SiteVideo>`

Once the assets are uploaded, render them with:

```vue
<SiteVideo
name="foo"
base-url="https://media.comfy.org/website/marketing"
:width="1280"
:formats="['webm', 'mp4']"
poster="https://media.comfy.org/website/marketing/foo-poster.jpg"
autoplay
loop
/>
```

### `<SiteVideo>` vs `<VideoPlayer>`

- **`SiteVideo`** — lightweight multi-source `<video>` for decorative or
autoplay marketing clips. No custom controls, no captions UI.
- **`VideoPlayer`** — full-featured player with custom scrubber, mute,
fullscreen, and caption toggles. Use this for content with subtitles or
user-driven playback.

If you need both responsive sources and the rich `VideoPlayer` chrome, the
two are not yet combined; either pick one or extend `VideoPlayer` to accept
a source list.

### Encoder choices

- **VP9/WebM** at CRF 32 — preferred by Chrome and Firefox; smaller files.
- **H.264/MP4** at CRF 23, High profile, `+faststart` — universal fallback,
required for Safari iOS.
- **Poster JPG** at q4 — extracted from t=1s when the clip is long enough,
otherwise t=0; scaled to 1280w. Use this as the `poster` attribute so
the video shows something while loading.

### Why a single resolution per video

`<source media="...">` inside `<video>` is unreliable across browsers
(Safari ignores it). The simplest correct strategy is to ship one
well-sized resolution and let CSS scale it down on smaller viewports.
The script generates multiple widths so you can pick a different one
per page (e.g. 1280w for a hero, 640w for a thumbnail), or wire up
JavaScript-based selection later if metrics demand it.
105 changes: 105 additions & 0 deletions apps/website/scripts/process-videos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
#
# Generate multi-resolution VP9/WebM + H.264/MP4 variants and a poster frame
# for every source video in a given directory. Intended to be run locally
# before uploading the outputs to media.comfy.org.
#
# Usage:
# apps/website/scripts/process-videos.sh <input-dir> <output-dir> [widths]
#
# Example:
# apps/website/scripts/process-videos.sh \
# ./video-sources \
# ./dist/videos \
# "640 960 1280 1920"
#
# Defaults to widths "1280" if omitted.
#
# Output naming matches buildVideoSources() in src/utils/video.ts:
# <name>-<width>.webm
# <name>-<width>.mp4
# <name>-poster.jpg (single 1280w poster, suitable for SiteVideo)
#
# Requires ffmpeg and ffprobe on PATH. Tested with ffmpeg 6.x and 7.x.

set -euo pipefail

if [[ $# -lt 2 ]]; then
cat <<USAGE >&2
Usage: $0 <input-dir> <output-dir> [widths]
widths: space-separated list, e.g. "640 1280 1920" (default: "1280")
USAGE
exit 64
fi

input_dir=$1
output_dir=$2
widths=${3:-1280}

for tool in ffmpeg ffprobe; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "error: $tool not found on PATH" >&2
exit 127
fi
done

if [[ ! -d $input_dir ]]; then
echo "error: input dir not found: $input_dir" >&2
exit 66
fi

mkdir -p "$output_dir"

shopt -s nullglob nocaseglob
sources=("$input_dir"/*.{mp4,mov,webm,mkv})
shopt -u nullglob nocaseglob

if [[ ${#sources[@]} -eq 0 ]]; then
echo "error: no source videos in $input_dir (looked for .mp4 .mov .webm .mkv)" >&2
exit 66
fi

for src in "${sources[@]}"; do
name=$(basename "$src")
name=${name%.*}
echo "==> $name"

for w in $widths; do
webm_out="$output_dir/${name}-${w}.webm"
mp4_out="$output_dir/${name}-${w}.mp4"

echo " encoding ${w}w VP9/WebM -> $webm_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libvpx-vp9 -b:v 0 -crf 32 -row-mt 1 -tile-columns 2 \
-c:a libopus -b:a 96k \
-f webm "$webm_out"

echo " encoding ${w}w H.264/MP4 -> $mp4_out"
ffmpeg -y -hide_banner -loglevel error \
-i "$src" \
-vf "scale=${w}:-2:flags=lanczos" \
-c:v libx264 -crf 23 -preset slow -profile:v high -pix_fmt yuv420p \
-c:a aac -b:a 128k \
-movflags +faststart \
"$mp4_out"
done

poster_out="$output_dir/${name}-poster.jpg"
duration=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src" 2>/dev/null || echo 0)
if awk "BEGIN { exit !($duration >= 1.0) }"; then
poster_seek=1
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
else
poster_seek=0
fi
echo " extracting poster (t=${poster_seek}s) -> $poster_out"
ffmpeg -y -hide_banner -loglevel error \
-ss "$poster_seek" -i "$src" \
-vframes 1 -vf "scale=1280:-2:flags=lanczos" \
-q:v 4 \
"$poster_out"
done

echo "done. upload contents of $output_dir to media.comfy.org."
51 changes: 51 additions & 0 deletions apps/website/src/assets/marketing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Marketing Assets

Source images committed here are processed by Astro at build time and emitted
as multiple formats (AVIF, WebP) at multiple widths (640w, 960w, 1280w, 1920w).

## Usage

Drop a high-resolution source image (PNG or JPG) here, then render it with
Astro's built-in `<Picture>` component plus the shared defaults:

```astro
---
import { Picture } from 'astro:assets'
import {
MARKETING_FORMATS,
MARKETING_WIDTHS
} from '../utils/marketingImage'
import hero from '../assets/marketing/hero.png'
---
<Picture
src={hero}
alt="ComfyUI workflow preview"
formats={[...MARKETING_FORMATS]}
widths={[...MARKETING_WIDTHS]}
sizes="(max-width: 768px) 100vw, 50vw"
/>
```

The component generates a `<picture>` element with `<source>` tags for AVIF
and WebP, plus an `<img>` fallback. Output files are hashed and emitted under
`dist/_website/` for long-term caching.

A custom Astro wrapper component is intentionally not provided: Astro's
discriminated union `LocalImageProps | RemoteImageProps` for `<Picture>` makes
a thin wrapper that mutates `widths` / `formats` impractical to type safely
without `as` casts. The shared constants give us the same consistency benefit
without that cost.

## When to use this vs. `media.comfy.org`

- **Use `src/assets/marketing/`** for static marketing images that are part of
page content (hero shots, product imagery, illustrations). Build-time
processing gives you AVIF/WebP variants automatically.
- **Use `media.comfy.org`** for video content, large/changing image libraries
(gallery), and anything shared across properties.

## Source image guidelines

- Provide the largest size you'll ever need (≥1920px wide).
- PNG for screenshots/illustrations with sharp edges; JPG for photographs.
- Astro will downscale; it will not upscale. Always supply at least 1920w.
68 changes: 68 additions & 0 deletions apps/website/src/components/common/SiteVideo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'

import { buildVideoSources, videoKey } from '../../utils/video'
import type { VideoFormat } from '../../utils/video'

const {
name,
baseUrl,
width = 1280,
formats = ['webm', 'mp4'],
poster,
alt,
autoplay = false,
loop = false,
muted = autoplay,
controls = false,
preload = autoplay ? 'auto' : 'metadata',
containerClass,
videoClass
} = defineProps<{
name: string
baseUrl: string
width?: number
formats?: VideoFormat[]
poster?: string
alt?: string
autoplay?: boolean
loop?: boolean
muted?: boolean
controls?: boolean
preload?: 'auto' | 'metadata' | 'none'
containerClass?: string
videoClass?: string
}>()

const sources = computed(() =>
buildVideoSources({ name, baseUrl, width, formats })
)
const remountKey = computed(() => videoKey(sources.value))
const decorative = computed(() => !alt && !controls)
</script>

<template>
<div :class="cn('relative', containerClass)">
<video
:key="remountKey"
:class="cn('size-full', videoClass)"
:poster
:preload
:autoplay
:loop
:muted
:controls
:aria-label="alt"
:aria-hidden="decorative ? true : undefined"
playsinline
>
<source
v-for="source in sources"
:key="source.src"
:src="source.src"
:type="source.type"
/>
</video>
</div>
</template>
3 changes: 3 additions & 0 deletions apps/website/src/utils/marketingImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const MARKETING_FORMATS = ['avif', 'webp'] as const

export const MARKETING_WIDTHS = [640, 960, 1280, 1920] as const
Loading
Loading