-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathsketchfab.js
More file actions
173 lines (155 loc) · 7.04 KB
/
Copy pathsketchfab.js
File metadata and controls
173 lines (155 loc) · 7.04 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
// @ts-check
// Sketchfab Data API v3 client for the official three.ws showcase account.
//
// Spec: https://docs.sketchfab.com/data-api/v3/index.html (swagger.json)
// - Auth: `Authorization: Token <key>` (account token, sketchfab.com/settings/password)
// - Upload: POST /v3/models, multipart/form-data. `modelFile` is the only
// required field; `tags`/`categories` are repeated form fields
// (collectionFormat "multi"); 201 returns { uid, uri }.
// - Status: processing is async. GET /v3/models/{uid} exposes
// status.processing: PROCESSING | SUCCEEDED | FAILED.
// - Limits: 50 MB per upload on the basic plan (200 MB pro, 500 MB biz).
//
// Sketchfab has no dedicated AI-generation field, so disclosure is the
// `ai-generated` tag plus a plain statement in the description. Every
// description carries the source prompt and UTM-tagged backlinks so referral
// conversion is measurable in analytics.
import { env } from './env.js';
const API_BASE = 'https://api.sketchfab.com/v3';
const NAME_MAX = 48; // Sketchfab truncates longer model names
const DESC_MAX = 1024; // description hard limit
// Basic-plan uploads cap at 50 MB; keep a margin so a boundary-size GLB never
// burns an attempt on a guaranteed 4xx.
export const GLB_MAX_BYTES = 45 * 1024 * 1024;
const SHOWCASE_UTM = 'utm_source=sketchfab&utm_medium=referral&utm_campaign=showcase';
// Brand-safety denylist for the OFFICIAL showcase account. Users can forge
// what the forge allows; the brand account does not publish firearms or
// explicit content under the three.ws name. Word-boundary matched, so
// "bullet train" style false positives are accepted as the cost of a
// conservative gate. This local list is always on; the NemoGuard classifier
// (api/_lib/moderation.js) runs as a second, fail-open layer in the cron.
const DENY_TERMS = [
'glock', 'gun', 'guns', 'firearm', 'firearms', 'rifle', 'pistol', 'shotgun',
'revolver', 'ammo', 'bullet', 'bullets', 'grenade', 'bomb', 'explosive',
'suppressor', 'silencer', 'nsfw', 'nude', 'naked', 'sex', 'sexy', 'porn',
'hentai', 'swastika',
];
const DENY_RE = new RegExp(`\\b(${DENY_TERMS.join('|')})\\b`, 'i');
// Returns the matched term when the prompt is brand-unsafe, else null.
export function promptDenyMatch(prompt) {
const m = String(prompt || '').match(DENY_RE);
return m ? m[1].toLowerCase() : null;
}
// Same list as a POSIX regex for `prompt !~* ...` in selection SQL, so
// obviously unsafe models never even appear as candidates (dry-run included).
// \m and \M are postgres word boundaries.
export const DENY_SQL_PATTERN = `\\m(${DENY_TERMS.join('|')})\\M`;
export function sketchfabConfigured() {
return Boolean(env.SKETCHFAB_API_TOKEN);
}
export function showcaseLink(path) {
const origin = env.APP_ORIGIN || 'https://three.ws';
return `${origin}${path}${path.includes('?') ? '&' : '?'}${SHOWCASE_UTM}`;
}
// Model name from the generation prompt: first clause, title-cased, article
// stripped, clamped to Sketchfab's display limit.
export function buildModelName(prompt) {
const trimmed = String(prompt || '').trim().replace(/^(a|an|the)\s+/i, '');
const firstClause = trimmed.split(/[,.;:\n]/)[0].trim() || trimmed || '3D Model';
const titled = firstClause.replace(/\b\w/g, (c) => c.toUpperCase());
if (titled.length <= NAME_MAX) return titled;
// Cut on a word boundary so the public name never ends mid-word.
const cut = titled.slice(0, NAME_MAX);
const lastSpace = cut.lastIndexOf(' ');
return (lastSpace > 20 ? cut.slice(0, lastSpace) : cut).trim();
}
// Sketchfab tags are slugs: lowercase, alphanumeric + hyphen.
function slugTag(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
export function buildTags(modelCategory) {
const tags = ['ai-generated', 'generative-ai', 'text-to-3d', 'threews'];
const cat = slugTag(modelCategory);
if (cat && !tags.includes(cat)) tags.push(cat);
return tags;
}
export function buildDescription({ prompt, creationId, source }) {
const pickLine =
source === 'board_winner'
? 'Weekly Forge-Off winner, crowned by community vote.'
: source === 'top_voted'
? 'Community pick: one of the top-voted models on the forge board.'
: 'Curated pick from the three.ws forge gallery.';
const share = showcaseLink(`/forge/share/${creationId}`);
const forge = showcaseLink('/forge');
const fixed = [
'',
`AI-generated on the three.ws Forge (text to 3D). ${pickLine}`,
'',
`View and remix in the browser: ${share}`,
`Forge your own, free, no account: ${forge}`,
].join('\n');
// The prompt is the only variable-length piece; clamp it so the backlinks
// always survive the 1024-char description limit.
const promptBudget = DESC_MAX - fixed.length - 12;
const cleanPrompt = String(prompt || '').trim().slice(0, Math.max(0, promptBudget));
return `Prompt: "${cleanPrompt}"${fixed}`.slice(0, DESC_MAX);
}
// Download the stored GLB and push it to Sketchfab. Returns { uid, url }.
// Throws on any failure; the caller records the error and retry budget.
export async function uploadModel({ glbUrl, name, description, tags }) {
const token = env.SKETCHFAB_API_TOKEN;
if (!token) throw new Error('SKETCHFAB_API_TOKEN unset');
const glbRes = await fetch(glbUrl, {
headers: { 'user-agent': 'threews-sketchfab-showcase/1.0' },
signal: AbortSignal.timeout(60_000),
});
if (!glbRes.ok) throw new Error(`glb fetch failed: ${glbRes.status} ${glbUrl}`);
const buf = await glbRes.arrayBuffer();
if (buf.byteLength === 0) throw new Error(`glb is empty: ${glbUrl}`);
if (buf.byteLength > GLB_MAX_BYTES) {
throw new Error(`glb too large for basic plan: ${buf.byteLength} bytes`);
}
const form = new FormData();
form.append(
'modelFile',
new Blob([buf], { type: 'model/gltf-binary' }),
`${slugTag(name) || 'model'}.glb`,
);
form.append('name', name);
form.append('description', description);
for (const tag of tags) form.append('tags', tag);
form.append('isPublished', 'true');
form.append('isInspectable', 'true');
const res = await fetch(`${API_BASE}/models`, {
method: 'POST',
headers: { authorization: `Token ${token}` },
body: form,
signal: AbortSignal.timeout(180_000),
});
const body = await res.json().catch(() => null);
if (res.status !== 201 || !body?.uid) {
throw new Error(
`sketchfab upload failed: ${res.status} ${JSON.stringify(body || {}).slice(0, 300)}`,
);
}
return { uid: body.uid, url: `https://sketchfab.com/models/${body.uid}` };
}
// Poll async processing. Returns 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | null
// (null = status not readable yet; treat as still processing).
export async function getProcessingStatus(uid) {
const token = env.SKETCHFAB_API_TOKEN;
if (!token) throw new Error('SKETCHFAB_API_TOKEN unset');
const res = await fetch(`${API_BASE}/models/${encodeURIComponent(uid)}`, {
headers: { authorization: `Token ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) return null;
const body = await res.json().catch(() => null);
const processing = body?.status?.processing;
return typeof processing === 'string' ? processing.toUpperCase() : null;
}