Shorts = portrait-only reel; Watch videos = landscape; badge name in gold

- Videos now carry client-detected pixel dimensions (videoW/videoH) captured at
  campaign create (upload or direct link). serveVideo takes an orientation filter:
  Shorts reel (/shorts) serves portrait (height>width); the Watch videos tab
  serves landscape, including legacy/unknown-dimension videos so nothing is orphaned.
- shorts.js requests the portrait feed and has a client guard that skips any
  landscape video that slips through. Create form shows the detected orientation.
- Achievement badge share image: member name now renders in gold with a dark
  outline (was low-contrast dark ink) and sits centered on the ribbon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 05:41:17 -05:00
parent 5e3f2540f4
commit a634f83a8e
7 changed files with 63 additions and 15 deletions
BIN
View File
Binary file not shown.
+41 -6
View File
@@ -80,12 +80,19 @@
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height;
const x = c.getContext('2d');
x.drawImage(img, 0, 0);
if (who) { // the blank ribbon sits at ~76% down; name it in dark ink on the mint banner
if (who) { // name it on the blank ribbon: gold with a dark outline so it reads on any badge color
x.textAlign = 'center';
x.fillStyle = '#04231b';
x.font = 'bold ' + Math.round(c.width * 0.052) + 'px Sora, "Segoe UI", sans-serif';
x.fillText(who, c.width / 2, c.height * 0.78);
x.textBaseline = 'middle';
x.font = 'bold ' + Math.round(c.width * 0.055) + 'px Sora, "Segoe UI", sans-serif';
const y = c.height * 0.82;
x.lineJoin = 'round';
x.lineWidth = Math.max(3, Math.round(c.width * 0.008));
x.strokeStyle = 'rgba(4,20,15,.9)';
x.strokeText(who, c.width / 2, y);
x.fillStyle = '#ffd15c';
x.fillText(who, c.width / 2, y);
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
}
try {
c.toBlob(bl => {
@@ -563,6 +570,13 @@
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
$('cVideoUrl').addEventListener('change', async () => {
const url = $('cVideoUrl').value.trim();
$('cVideoInfo').textContent = url ? 'checking video…' : '';
cVidDims = url ? await probeVideoDims(url) : null;
if (url && !cVidDims) $('cVideoInfo').textContent = 'could not read that video';
else { $('cVideoInfo').textContent = ''; showVidOrient(); }
});
$('cVideoFile').addEventListener('change', async () => {
const f = $('cVideoFile').files[0];
if (!f) return;
@@ -574,6 +588,7 @@
$('cVideoInfo').textContent = f.name + ' uploaded';
$('cVideoPrev').hidden = false;
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
cVidDims = await probeVideoDims(r.url); showVidOrient();
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
$('cVideoFile').value = '';
});
@@ -583,11 +598,13 @@
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
const t = $('cType').value;
const isVideo = t === 'video', isFeat = t === 'featured';
if (isVideo && !cVidDims && $('cVideoUrl').value) cVidDims = await probeVideoDims($('cVideoUrl').value);
await api('/api/my/campaigns', { type: t, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
body: t === 'solo' ? soloBody : $('cBody').value,
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
videoW: cVidDims ? cVidDims.w : null, videoH: cVidDims ? cVidDims.h : null,
days: Number($('cFeatDays').value), startDay: featStartDay,
count: Number($('cVisitCount').value),
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
@@ -672,9 +689,27 @@
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
// detected dimensions of the video being created — portrait goes to Shorts, landscape to Watch videos
let cVidDims = null;
function probeVideoDims(url) {
return new Promise(resolve => {
if (!url) return resolve(null);
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
let done = false; const fin = r => { if (!done) { done = true; resolve(r); } };
v.onloadedmetadata = () => fin(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
v.onerror = () => fin(null); setTimeout(() => fin(null), 8000); v.src = url;
});
}
function showVidOrient() {
const el = $('cVideoInfo'); if (!el) return;
if (!cVidDims) return;
const portrait = cVidDims.h > cVidDims.w;
el.textContent = (el.textContent ? el.textContent + ' · ' : '')
+ (portrait ? 'portrait — shows in the Shorts reel' : 'landscape — shows in the Watch videos tab');
}
async function loadVideoStatus() {
try {
const st = await (await fetch('/api/my/videos')).json();
const st = await (await fetch('/api/my/videos?orientation=landscape')).json();
if (st.error) return;
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
if (st.status.left <= 0) {
@@ -687,7 +722,7 @@
}
async function loadVideoAd() {
let r = null;
try { r = await (await fetch('/api/my/videos')).json(); } catch (e) {}
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
if (!r || !r.ad) {
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
? 'That is today\'s video set. Come back tomorrow.'
+9 -2
View File
@@ -3,7 +3,7 @@
// then advance to the next. Reuses /api/my/videos + /api/my/videowatch.
(function () {
const $ = id => document.getElementById(id);
const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false };
const st = { token: null, secs: 0, maxSeen: 0, credited: false, done: false, skips: 0 };
function j(url, body) {
return fetch(url, body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
@@ -13,7 +13,7 @@
st.token = null; st.maxSeen = 0; st.credited = false; st.done = false;
$('shOver').hidden = true; $('shCta').hidden = true; $('shNext').hidden = true;
let r = null;
try { r = await j('/api/my/videos'); } catch (e) {}
try { r = await j('/api/my/videos?orientation=portrait'); } catch (e) {}
if (!r || r.error) { $('shVideo').hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'Sign in on the dashboard to watch shorts and earn.'; return; }
$('shStat').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap;
if (!r.ad) {
@@ -25,6 +25,13 @@
const v = $('shVideo');
$('shMsg').hidden = true; v.hidden = false;
v.src = r.ad.videoUrl; v.currentTime = 0;
// safety net: this reel is portrait-only. If a landscape video slips through, skip to the next.
v.onloadedmetadata = () => {
if (v.videoWidth && v.videoHeight && v.videoWidth > v.videoHeight) {
if (st.skips++ < 4) { load(); return; }
v.hidden = true; $('shMsg').hidden = false; $('shMsg').textContent = 'No shorts in rotation right now. Check back soon.';
} else { st.skips = 0; }
};
$('shTitle').textContent = r.ad.title || '';
$('shCta').href = r.ad.ctaUrl; $('shCta').textContent = r.ad.ctaLabel || 'Learn more';
$('shOver').hidden = false; $('shCta').hidden = false;
+5 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260907b">
<link rel="stylesheet" href="/assets/site.css?v=20260907c">
</head>
<body class="bo-body">
@@ -627,9 +627,9 @@
</div>
</div>
</div>
<script src="/assets/common.js?v=20260907b"></script>
<script src="/assets/wallet.js?v=20260907b"></script>
<script src="/assets/my.js?v=20260907b"></script>
<script src="/assets/chat.js?v=20260907b"></script>
<script src="/assets/common.js?v=20260907c"></script>
<script src="/assets/wallet.js?v=20260907c"></script>
<script src="/assets/my.js?v=20260907c"></script>
<script src="/assets/chat.js?v=20260907c"></script>
</body>
</html>
+1 -1
View File
@@ -43,6 +43,6 @@
</div>
</div>
</div>
<script src="/assets/shorts.js?v=20260906m"></script>
<script src="/assets/shorts.js?v=20260907c"></script>
</body>
</html>
+2 -1
View File
@@ -867,7 +867,8 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const status = await ads.videoStatus(s.email);
if (status.left <= 0) return json(res, 200, { ad: null, status });
const ad = await ads.serveVideo({ excludeEmail: s.email }); // never your own video
const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab
const ad = await ads.serveVideo({ excludeEmail: s.email, orientation }); // never your own video
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
+5
View File
@@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 1791369645 iap.sid 2b2c027da3d3e566d594e406c3bd05a175a7845d7ffa702be92fd923ac18d432