web: the landing draws only the stills the studio uploaded
Six bundled sample negatives stood behind eight built-in looks, and they were the only frames on the page nobody had uploaded. They are gone, along with the REEL table and the SAMPLE() helper that pointed at them. The film strip is now exactly the photos the curator put in the strip slot, repeated once for the marquee loop; a section whose slot holds nothing simply draws no frame instead of falling back to a stock photo. The reel's rating keys are all photo:<id> now, and the QR card's filter follows the sunset preset it claims rather than an index that moved.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 137 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 117 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 176 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
@@ -22,8 +22,6 @@ const STORE_LINKS = {
|
||||
|
||||
type Txt = { en: string; vi: string };
|
||||
|
||||
const SAMPLE = (n: number) => `/assets/samples/s${n}.jpg`;
|
||||
|
||||
// No page can really stop a save — the bytes are already on the machine. What it
|
||||
// can do is refuse to hand over more pixels than it draws: the photo is decoded,
|
||||
// redrawn at the size of its box times the screen's pixel ratio (2x at most) and
|
||||
@@ -90,27 +88,11 @@ interface Look {
|
||||
meta: string;
|
||||
photo: string;
|
||||
filter: string;
|
||||
// The subject this frame is rated under: `look:<TAG>` for a built-in look,
|
||||
// `photo:<id>` for a contribution. Filled in where the reel is assembled.
|
||||
// The subject this frame is rated under: `photo:<id>`. Stamped when the reel
|
||||
// is assembled so the literal cannot drift from the key form.
|
||||
rateKey: string;
|
||||
}
|
||||
|
||||
// 35mm reel: eight looks over six sample negatives, each with its own CSS
|
||||
// emulation. ponytail: CSS filters, not the CanvasKit pipeline — a landing page
|
||||
// does not need a 12MP render; swap in real graded stills when we have them.
|
||||
// The built-in looks carry no `rateKey` of their own — the reel stamps it from
|
||||
// the tag when it is assembled, so the literal cannot drift from the key form.
|
||||
const REEL: Omit<Look, 'rateKey'>[] = [
|
||||
{ tag: '#KODAK_PORTRA_400', title: { en: 'Golden Hour Portrait', vi: 'Chân dung giờ vàng' }, meta: 'ISO 400 · GRAIN 35 · WARMTH +18', photo: SAMPLE(1015), filter: 'sepia(.16) saturate(1.18) contrast(1.04) brightness(1.04)' },
|
||||
{ tag: '#FUJI_CLASSIC_CHROME', title: { en: 'Quiet Street, 7am', vi: 'Phố vắng, 7 giờ sáng' }, meta: 'ISO 200 · GRAIN 20 · WARMTH -6', photo: SAMPLE(1025), filter: 'saturate(.78) contrast(1.14) hue-rotate(-6deg)' },
|
||||
{ tag: '#CYBER_NEON_88', title: { en: 'Neon Crossing', vi: 'Ngã tư neon' }, meta: 'ISO 1600 · GRAIN 45 · WARMTH +4', photo: SAMPLE(1035), filter: 'saturate(1.7) contrast(1.25) hue-rotate(268deg)' },
|
||||
{ tag: '#CINESTILL_800T', title: { en: 'Harbour Tungsten', vi: 'Cảng đèn vàng' }, meta: 'ISO 800 · GRAIN 40 · WARMTH -14', photo: SAMPLE(1043), filter: 'saturate(1.1) contrast(1.1) hue-rotate(186deg) brightness(.96)' },
|
||||
{ tag: '#ILFORD_HP5_BW', title: { en: 'Alley Study', vi: 'Hẻm nhỏ' }, meta: 'ISO 400 · GRAIN 55 · MONO', photo: SAMPLE(1050), filter: 'grayscale(1) contrast(1.35) brightness(1.02)' },
|
||||
{ tag: '#VELVIA_50', title: { en: 'Ridge Line', vi: 'Sống núi' }, meta: 'ISO 50 · GRAIN 12 · WARMTH +6', photo: SAMPLE(1069), filter: 'saturate(1.55) contrast(1.22)' },
|
||||
{ tag: '#ACROS_100', title: { en: 'Stone Facade', vi: 'Mặt tường đá' }, meta: 'ISO 100 · GRAIN 30 · MONO', photo: SAMPLE(1025), filter: 'grayscale(1) contrast(1.18) brightness(1.06)' },
|
||||
{ tag: '#ETRNA_VINTAGE', title: { en: 'Faded Postcard', vi: 'Bưu thiếp bạc màu' }, meta: 'ISO 200 · GRAIN 25 · WARMTH +10', photo: SAMPLE(1015), filter: 'sepia(.28) saturate(.9) contrast(.94) brightness(1.08)' },
|
||||
];
|
||||
|
||||
type PresetGroup = 'landscape' | 'portrait' | 'streetlife';
|
||||
|
||||
interface Preset {
|
||||
@@ -291,13 +273,11 @@ export function Landing() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Contributed stills join the reel after the six built-in looks. Every frame
|
||||
// is repeated once, so the marquee's -50% translation still loops seamlessly.
|
||||
// The reel holds only stills the studio's own uploads put in the strip slot.
|
||||
// Every frame is repeated once, so the marquee's -50% translation still loops
|
||||
// seamlessly.
|
||||
const reel = useMemo<Look[]>(
|
||||
() => [
|
||||
// A built-in look is rated under its own tag, so the eight samples carry
|
||||
// scores just like a contributed frame does.
|
||||
...REEL.map((l) => ({ ...l, rateKey: `look:${l.tag.replace(/^#/, '')}` })),
|
||||
// The labels the uploader's studio stored — the tagline, the title and
|
||||
// the ISO/grain line. A contribution from before those existed keeps the
|
||||
// plain community defaults.
|
||||
@@ -315,33 +295,31 @@ export function Landing() {
|
||||
|
||||
// The three live sections. One random photo out of each set, drawn once per
|
||||
// page load so the preview does not change under the visitor's eyes on every
|
||||
// render; the built-in sample stands in while a section has no curation. A
|
||||
// photo the curator put in several sections can turn up in each of them.
|
||||
// render; a section whose slot holds nothing simply shows no frame. A photo
|
||||
// the curator put in several sections can turn up in each of them.
|
||||
const slotPhoto = useMemo(() => {
|
||||
const pick = (slot: PhotoSlot) => {
|
||||
const list = photos.filter((p) => p.slots.includes(slot));
|
||||
return list.length ? list[Math.floor(Math.random() * list.length)] : null;
|
||||
};
|
||||
const src = (slot: PhotoSlot, fallback: string) => {
|
||||
const src = (slot: PhotoSlot) => {
|
||||
const p = pick(slot);
|
||||
return p ? api.photoUrl(p.id) : fallback;
|
||||
return p ? api.photoUrl(p.id) : null;
|
||||
};
|
||||
// The QR card's payload is the curated photo's own look, as the link the
|
||||
// phone app downloads it from. The card's mock address stands in while the
|
||||
// section holds nothing that has a look to hand out.
|
||||
// phone app downloads it from.
|
||||
// The tester cycles every still the curator put in its slot, so a recipe
|
||||
// can be checked against more than one frame. The built-in sample stands
|
||||
// in on its own while the slot holds nothing.
|
||||
// can be checked against more than one frame.
|
||||
const curated = photos.filter((p) => p.slots.includes('tester')).map((p) => api.photoUrl(p.id));
|
||||
const testers = curated.length ? curated : [SAMPLE(1043)];
|
||||
const testers = curated;
|
||||
const qr = pick('qr');
|
||||
return {
|
||||
testers,
|
||||
// A plain step count on top of one random start keeps the frame random
|
||||
// per load without an effect that would reset it on every re-render.
|
||||
testerStart: Math.floor(Math.random() * testers.length),
|
||||
creator: src('creator', SAMPLE(1015)),
|
||||
qr: qr ? api.photoUrl(qr.id) : SAMPLE(1069),
|
||||
creator: src('creator'),
|
||||
qr: qr ? api.photoUrl(qr.id) : null,
|
||||
qrLink: qr?.hasPreset ? new URL(api.presetUrl(qr.id), window.location.origin).href : `${window.location.origin}/app`,
|
||||
};
|
||||
}, [photos]);
|
||||
@@ -724,11 +702,13 @@ export function Landing() {
|
||||
</select>
|
||||
</label>
|
||||
<div className="lp-view">
|
||||
<Shot
|
||||
src={slotPhoto.testers[(slotPhoto.testerStart + testerIdx) % slotPhoto.testers.length]}
|
||||
alt={`${preset.name} preview`}
|
||||
filter={preset.filter}
|
||||
/>
|
||||
{slotPhoto.testers.length > 0 && (
|
||||
<Shot
|
||||
src={slotPhoto.testers[(slotPhoto.testerStart + testerIdx) % slotPhoto.testers.length]}
|
||||
alt={`${preset.name} preview`}
|
||||
filter={preset.filter}
|
||||
/>
|
||||
)}
|
||||
{slotPhoto.testers.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
@@ -787,11 +767,13 @@ export function Landing() {
|
||||
</div>
|
||||
<div className="lp-lab">
|
||||
<div className="lp-view">
|
||||
<Shot
|
||||
src={slotPhoto.creator}
|
||||
alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })}
|
||||
filter={labFilter}
|
||||
/>
|
||||
{slotPhoto.creator && (
|
||||
<Shot
|
||||
src={slotPhoto.creator}
|
||||
alt={c({ en: 'Custom recipe preview', vi: 'Xem trước công thức riêng' })}
|
||||
filter={labFilter}
|
||||
/>
|
||||
)}
|
||||
<div className="lp-hud"><span>{c({ en: 'CUSTOM RECIPE', vi: 'CÔNG THỨC RIÊNG' })}</span><span>TEMP <b>{temp > 0 ? `+${temp}` : temp}</b></span><span>SAT <b>{sat}%</b></span><span>CON <b>{con}%</b></span></div>
|
||||
</div>
|
||||
<div className="lp-knobs">
|
||||
@@ -850,11 +832,13 @@ export function Landing() {
|
||||
<div className="lp-qr">
|
||||
<div className="lp-recipe-card">
|
||||
<div className="lp-shot">
|
||||
<Shot
|
||||
src={slotPhoto.qr}
|
||||
alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })}
|
||||
filter={PRESETS[2].filter}
|
||||
/>
|
||||
{slotPhoto.qr && (
|
||||
<Shot
|
||||
src={slotPhoto.qr}
|
||||
alt={c({ en: 'Shared recipe card', vi: 'Thẻ công thức được chia sẻ' })}
|
||||
filter={(PRESETS.find((p) => p.id === 'sunset') ?? PRESETS[0]).filter}
|
||||
/>
|
||||
)}
|
||||
<span className="lp-grain" aria-hidden="true" />
|
||||
<span className="lp-tagline">#SUNSET_GLOW</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user