-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathsketchfab-showcase.js
More file actions
310 lines (290 loc) · 10.7 KB
/
Copy pathsketchfab-showcase.js
File metadata and controls
310 lines (290 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
// @ts-check
// GET /api/cron/sketchfab-showcase: curated Sketchfab distribution for SEO.
//
// Runs Mon/Wed/Fri and pushes up to SKETCHFAB_UPLOADS_PER_RUN (default 2, so
// up to 6/week) of the best community-validated forge models to the official
// three.ws Sketchfab account. Selection order:
//
// 1. Weekly Forge-Off winners (forge_board_winners) not yet uploaded: the
// strongest human-curation signal on the platform.
// 2. Top-voted board models (forge_creations.vote_count >= 1): at least one
// real community vote.
// 3. Creator-validated models (outcome = 'accepted' or downloaded = true):
// the creator explicitly accepted the generation or took the GLB. This
// tier seeds the showcase while board voting ramps up; raw unreviewed
// output is never pushed. Refinement children (parent_creation_id set)
// are excluded: their prompts are instructions ("add robot legs"), not
// titles.
//
// Brand safety: the official account never publishes firearms or explicit
// content. A local denylist filters the selection SQL itself (dry-run
// included) and re-checks before upload; the NemoGuard classifier
// (moderation.js) runs as a second, fail-open layer. A blocked creation is
// parked in the ledger with status 'blocked' so it is never re-picked.
//
// Every upload is tagged `ai-generated`, carries the source prompt, and
// backlinks to the creation's share page + /forge with UTM parameters
// (utm_source=sketchfab) so referral conversion is measurable. This is a
// showcase, not a firehose: the per-run cap and the vote floor keep the
// account curated.
//
// Each run also refreshes the async processing status of recent uploads
// (uploaded -> live | failed).
//
// Skips cleanly when SKETCHFAB_API_TOKEN is unset. `?dry_run=1` returns the
// current selection without uploading anything.
import { json, method, wrapCron } from '../_lib/http.js';
import { env } from '../_lib/env.js';
import { sql } from '../_lib/db.js';
import { constantTimeEquals } from '../_lib/crypto.js';
import {
DENY_SQL_PATTERN,
GLB_MAX_BYTES,
buildDescription,
buildModelName,
buildTags,
getProcessingStatus,
promptDenyMatch,
sketchfabConfigured,
uploadModel,
} from '../_lib/sketchfab.js';
import { moderateAnonInput } from '../_lib/moderation.js';
const MAX_ATTEMPTS = 3;
function requireCron(req, res) {
const secret = process.env.CRON_SECRET || env.CRON_SECRET;
if (!secret) {
res.status(503).json({ error: 'not_configured', message: 'CRON_SECRET unset' });
return false;
}
const auth = req.headers['authorization'] || '';
const presented = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!constantTimeEquals(presented, secret)) {
res.status(401).json({ error: 'unauthorized' });
return false;
}
return true;
}
function uploadsPerRun() {
const n = Number(env.SKETCHFAB_UPLOADS_PER_RUN || 2);
if (!Number.isFinite(n)) return 2;
return Math.min(5, Math.max(1, Math.floor(n)));
}
// A creation is eligible when it is publicly showable (same bar as the forge
// gallery), fits the Sketchfab basic-plan size cap, and has no ledger row
// blocking it (uploaded/live/pending, or failed with the retry budget spent).
async function selectCandidates(limit) {
const winners = await sql`
select fc.id, fc.prompt, fc.glb_url, fc.model_category, 'board_winner' as source
from forge_board_winners w
join forge_creations fc on fc.id = w.creation_id
where fc.status = 'done'
and fc.glb_url is not null
and (fc.outcome is null or fc.outcome != 'rejected')
and fc.prompt !~* ${DENY_SQL_PATTERN}
and coalesce(fc.size_bytes, 0) <= ${GLB_MAX_BYTES}
and not exists (
select 1 from sketchfab_uploads su
where su.creation_id = fc.id
and (su.status != 'failed' or su.attempts >= ${MAX_ATTEMPTS})
)
order by w.week_start desc
limit ${limit}
`;
if (winners.length >= limit) return winners;
const taken = winners.map((w) => w.id);
const topVoted = await sql`
select fc.id, fc.prompt, fc.glb_url, fc.model_category, 'top_voted' as source
from forge_creations fc
where fc.status = 'done'
and fc.glb_url is not null
and (fc.outcome is null or fc.outcome != 'rejected')
and fc.vote_count >= 1
and fc.prompt !~* ${DENY_SQL_PATTERN}
and coalesce(fc.size_bytes, 0) <= ${GLB_MAX_BYTES}
and fc.id != all(${taken}::uuid[])
and not exists (
select 1 from sketchfab_uploads su
where su.creation_id = fc.id
and (su.status != 'failed' or su.attempts >= ${MAX_ATTEMPTS})
)
order by fc.vote_count desc, fc.created_at desc
limit ${limit - winners.length}
`;
const picked = [...winners, ...topVoted];
if (picked.length >= limit) return picked;
taken.push(...topVoted.map((t) => t.id));
const accepted = await sql`
select fc.id, fc.prompt, fc.glb_url, fc.model_category, 'accepted' as source
from forge_creations fc
where fc.status = 'done'
and fc.glb_url is not null
and (fc.outcome = 'accepted' or fc.downloaded = true)
and fc.prompt is not null
and fc.prompt !~* ${DENY_SQL_PATTERN}
and fc.parent_creation_id is null
-- Title quality: skip placeholder prompts from the image path and
-- instruction-shaped prompts ("add robot legs"), which make
-- meaningless public model names. Winners/voted tiers are already
-- human-curated and skip this heuristic.
and length(fc.prompt) >= 12
and fc.prompt != 'image-to-3d'
and fc.prompt !~* '^(add|remove|make|change|fix|update)\\M'
and coalesce(fc.size_bytes, 0) <= ${GLB_MAX_BYTES}
and fc.id != all(${taken}::uuid[])
and not exists (
select 1 from sketchfab_uploads su
where su.creation_id = fc.id
and (su.status != 'failed' or su.attempts >= ${MAX_ATTEMPTS})
)
order by fc.created_at desc
limit ${limit - picked.length}
`;
return [...picked, ...accepted];
}
// Claim the creation in the ledger before touching the network so concurrent
// runs (or a Scheduler retry) can never double-upload. Fresh creations insert;
// a prior failure re-claims only while attempts remain.
async function claimCreation(candidate) {
const [row] = await sql`
insert into sketchfab_uploads (creation_id, source, status, attempts, prompt, glb_url)
values (${candidate.id}, ${candidate.source}, 'pending', 1, ${candidate.prompt}, ${candidate.glb_url})
on conflict (creation_id) do update
set attempts = sketchfab_uploads.attempts + 1,
status = 'pending',
updated_at = now()
where sketchfab_uploads.status = 'failed'
and sketchfab_uploads.attempts < ${MAX_ATTEMPTS}
returning id
`;
return row?.id || null;
}
// Local denylist + NemoGuard verdict. Returns a block reason or null.
async function contentBlockReason(prompt) {
const term = promptDenyMatch(prompt);
if (term) return `denylist:${term}`;
const verdict = await moderateAnonInput(prompt).catch(() => ({ flagged: false }));
if (verdict?.flagged) {
return `moderation:${(verdict.categories || []).join(',') || 'unsafe'}`;
}
return null;
}
async function pushCandidate(candidate) {
const ledgerId = await claimCreation(candidate);
if (!ledgerId) return { id: candidate.id, status: 'skipped', reason: 'already_claimed' };
const blockReason = await contentBlockReason(candidate.prompt);
if (blockReason) {
await sql`
update sketchfab_uploads
set status = 'blocked', error = ${blockReason}, updated_at = now()
where id = ${ledgerId}
`;
return { id: candidate.id, status: 'blocked', source: candidate.source, reason: blockReason };
}
try {
const name = buildModelName(candidate.prompt);
const { uid, url } = await uploadModel({
glbUrl: candidate.glb_url,
name,
description: buildDescription({
prompt: candidate.prompt,
creationId: candidate.id,
source: candidate.source,
}),
tags: buildTags(candidate.model_category),
});
await sql`
update sketchfab_uploads
set status = 'uploaded', sketchfab_uid = ${uid}, sketchfab_url = ${url},
error = null, updated_at = now()
where id = ${ledgerId}
`;
return { id: candidate.id, status: 'uploaded', source: candidate.source, name, uid, url };
} catch (err) {
const message = String(err?.message || err).slice(0, 500);
await sql`
update sketchfab_uploads
set status = 'failed', error = ${message}, updated_at = now()
where id = ${ledgerId}
`.catch(() => {});
return { id: candidate.id, status: 'failed', source: candidate.source, error: message };
}
}
// Move recent uploads through Sketchfab's async pipeline: uploaded -> live on
// SUCCEEDED, -> failed on FAILED (with attempts exhausted, since reprocessing
// the same GLB fails the same way).
async function refreshProcessing() {
const rows = await sql`
select id, sketchfab_uid from sketchfab_uploads
where status = 'uploaded' and sketchfab_uid is not null
order by updated_at asc
limit 10
`;
const refreshed = [];
for (const row of rows) {
try {
const state = await getProcessingStatus(row.sketchfab_uid);
if (state === 'SUCCEEDED') {
await sql`
update sketchfab_uploads set status = 'live', updated_at = now() where id = ${row.id}
`;
refreshed.push({ uid: row.sketchfab_uid, status: 'live' });
} else if (state === 'FAILED') {
await sql`
update sketchfab_uploads
set status = 'failed', attempts = ${MAX_ATTEMPTS},
error = 'sketchfab processing failed', updated_at = now()
where id = ${row.id}
`;
refreshed.push({ uid: row.sketchfab_uid, status: 'failed' });
}
} catch {
// Transient status-read failure: the next run retries.
}
}
return refreshed;
}
export default wrapCron(async (req, res) => {
if (!method(req, res, ['GET'])) return;
if (!requireCron(req, res)) return;
const url = new URL(req.url || '/', 'https://three.ws');
const dryRun = url.searchParams.get('dry_run') === '1';
// Dry-run only reads the DB, so it works before the Sketchfab token is
// wired: it previews exactly what the next real run would pick.
if (!dryRun && !sketchfabConfigured()) {
return json(res, 200, {
ok: false,
reason: 'not_configured',
message: 'SKETCHFAB_API_TOKEN unset; showcase cron is dormant',
});
}
const limit = uploadsPerRun();
const candidates = await selectCandidates(limit);
if (dryRun) {
return json(res, 200, {
ok: true,
dry_run: true,
configured: sketchfabConfigured(),
limit,
candidates: candidates.map((c) => ({
id: c.id,
source: c.source,
name: buildModelName(c.prompt),
prompt: c.prompt,
glb_url: c.glb_url,
})),
});
}
const refreshed = await refreshProcessing();
const results = [];
for (const candidate of candidates) {
results.push(await pushCandidate(candidate));
}
return json(res, 200, {
ok: true,
uploaded: results.filter((r) => r.status === 'uploaded').length,
failed: results.filter((r) => r.status === 'failed').length,
blocked: results.filter((r) => r.status === 'blocked').length,
refreshed,
results,
});
});