@velven/cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - A `clip` in `velven.json` must be 5 to 10 seconds long. The CLI reads the length from the file's header and stops with
6
+ exit 2 (`invalid_media`) when it's outside that range. A file that doesn't say its length still goes through.
7
+ - A `thumbnail` must be 4096 pixels or less on each side. The CLI reads the size from the picture's header and stops
8
+ with exit 2 (`invalid_media`) when it's larger.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - `velven publish --prod` is usually live when the command ends: Velven reads every file of the version, and a clean
package/README.md CHANGED
@@ -38,16 +38,29 @@ Optional: `description`, `engine`, `ai_tools`, `models`, `how_made`, `source_url
38
38
  | `spa` | `true` serves the entry page for any path without a file (client-side routing) |
39
39
  | `sdk` | `false` stops Velven adding the SDK's script tag (set it when you bundle `@velven/sdk` yourself) |
40
40
  | `start` | How to get past a start screen, so Velven can look at the space and record its clip: `{ "click": "Play" }`, `{ "click": [x, y] }` or `{ "key": "Space" }` |
41
- | `thumbnail` | A picture in the folder for the space's tile: JPEG, PNG or WebP, up to 2 MB |
42
- | `clip` | A video in the folder the tile plays on hover: MP4 or WebM, up to 3 MB (five seconds at 480p); without a `thumbnail` its first frame is the picture |
41
+ | `thumbnail` | A picture in the folder for your space's tile. JPEG, PNG or WebP, up to 2 MB and 4096 pixels on each side |
42
+ | `clip` | A video in the folder that your tile plays on hover. MP4 or WebM, up to 3 MB and 5 to 10 seconds long. Without a `thumbnail`, its first frame is the picture |
43
43
  | `boards`, `achievements`, `stats`, `toasts` | Leaderboards, achievements and stats, declared as in the SDK docs |
44
44
  | `space` | Written by the first publish; later publishes update that space |
45
45
  | `claim` | Written by a publish without an account; it updates the unlisted page until you claim it |
46
46
 
47
- `thumbnail` and `clip` must be files the publish sends (not left out by `.velvenignore`), of their kind by name and by
48
- their first bytes, and within their size: `velven publish` checks them before uploading anything and stops with exit 2
49
- (`invalid_media`) naming what is wrong. Velven then records nothing for the tile; a later version that names neither, or
50
- the same files, keeps what the space shows, a clip recorded again or uploaded on the Clip tab included.
47
+ `velven publish` checks `thumbnail` and `clip` before it uploads anything. Each one must be:
48
+
49
+ - a file the publish sends, so `.velvenignore` can't leave it out
50
+ - the kind of file its name says, inside as well as in the name
51
+ - within its size: 2 MB for a thumbnail, 3 MB for a clip
52
+ - 4096 pixels or less on each side, for a thumbnail
53
+ - 5 to 10 seconds long, for a clip
54
+
55
+ If one isn't, the CLI stops with exit 2 (`invalid_media`) and tells you what to fix. Some files don't say how long
56
+ they are. The CLI lets those through, and Velven measures the clip once your space is live. It uses the first 10
57
+ seconds of a longer clip. It drops a clip under 5 seconds and records its own, unless you named a `thumbnail`.
58
+
59
+ Name either one and Velven records nothing for your tile. A later version that names neither, or names the same files,
60
+ keeps what your space shows. That includes a clip you recorded again or uploaded on the Clip tab.
61
+
62
+ Your thumbnail and clip follow the same content policy as your space. They must show your space itself, not something
63
+ unrelated or misleading.
51
64
 
52
65
  A `.velvenignore` file leaves files out: one pattern a line, `*`, `**` and `?`, `folder/` for folders, `#` comments.
53
66
  Dotfiles, `node_modules` and every `velven.json` are always left out, in any case, as is a name that ends in a dot or a space or holds `:` or `~` (the CLI names each such file);
@@ -123,7 +136,7 @@ Set `VELVEN_TOKEN` to a token from `velven login` (in `~/.config/velven/auth.jso
123
136
  | --- | --- |
124
137
  | 0 | Done |
125
138
  | 1 | Failed: the network, a server error, a failed upload, or cancelled |
126
- | 2 | A mistake in the command or in `velven.json`, such as a missing field, or a `thumbnail` or `clip` that is not right (`invalid_media`) |
139
+ | 2 | A mistake in the command or in `velven.json`, such as a missing field, or a `thumbnail` or `clip` that breaks one of the rules above (`invalid_media`) |
127
140
  | 3 | Not signed in (or the token was refused) for something that needs an account, such as `--prod` |
128
141
  | 4 | Refused by Velven: not your space, too large, too many files, a program, installer or coin miner in the folder (`refused_file`, before anything is uploaded), not found |
129
142
  | 5 | Rate limited: try again later |
package/dist/velven.js CHANGED
@@ -23,7 +23,7 @@ class CliError extends Error {
23
23
  }
24
24
  }
25
25
  // package.json
26
- var version = "0.2.0";
26
+ var version = "0.2.1";
27
27
 
28
28
  // src/version.ts
29
29
  var VERSION = version;
@@ -973,17 +973,206 @@ function isClip(bytes) {
973
973
  return isMp4(bytes) || isWebm(bytes);
974
974
  }
975
975
 
976
+ // ../../src/lib/media-header.ts
977
+ var u16 = (b, i) => b[i] << 8 | b[i + 1];
978
+ var u32 = (b, i) => (b[i] << 24 | b[i + 1] << 16 | b[i + 2] << 8 | b[i + 3]) >>> 0;
979
+ var u64 = (b, i) => u32(b, i) * 2 ** 32 + u32(b, i + 4);
980
+ var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
981
+ function mp4Seconds(b) {
982
+ const boxes = (start, end, visit) => {
983
+ for (let at = start;at + 8 <= end; ) {
984
+ let size = u32(b, at);
985
+ const type = ascii(b, at + 4, 4);
986
+ let body = at + 8;
987
+ if (size === 1) {
988
+ if (at + 16 > end)
989
+ return null;
990
+ size = u64(b, at + 8);
991
+ body = at + 16;
992
+ } else if (size === 0)
993
+ size = end - at;
994
+ if (size < body - at || at + size > end)
995
+ return null;
996
+ const found = visit(type, body, at + size);
997
+ if (found !== undefined)
998
+ return found;
999
+ at += size;
1000
+ }
1001
+ return null;
1002
+ };
1003
+ return boxes(0, b.length, (type, body, end) => type !== "moov" ? undefined : boxes(body, end, (inner, at, innerEnd) => {
1004
+ if (inner !== "mvhd")
1005
+ return;
1006
+ const version2 = b[at];
1007
+ const scaleAt = version2 === 1 ? at + 20 : at + 12;
1008
+ if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
1009
+ return null;
1010
+ const scale = u32(b, scaleAt);
1011
+ const duration = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
1012
+ if (!scale || !duration || duration === 4294967295)
1013
+ return null;
1014
+ return duration / scale;
1015
+ }));
1016
+ }
1017
+ function ebmlId(b, at) {
1018
+ const first = b[at];
1019
+ const length = first >= 128 ? 1 : first >= 64 ? 2 : first >= 32 ? 3 : first >= 16 ? 4 : 0;
1020
+ if (!length || at + length > b.length)
1021
+ return null;
1022
+ let id = 0;
1023
+ for (let i = 0;i < length; i++)
1024
+ id = id * 256 + b[at + i];
1025
+ return { id, next: at + length };
1026
+ }
1027
+ function ebmlSize(b, at) {
1028
+ const first = b[at];
1029
+ let length = 1;
1030
+ while (length <= 8 && !(first & 128 >> length - 1))
1031
+ length++;
1032
+ if (length > 8 || at + length > b.length)
1033
+ return null;
1034
+ let size = first & 255 >> length;
1035
+ let allOnes = size === 255 >> length;
1036
+ for (let i = 1;i < length; i++) {
1037
+ size = size * 256 + b[at + i];
1038
+ allOnes &&= b[at + i] === 255;
1039
+ }
1040
+ return { size: allOnes ? null : size, next: at + length };
1041
+ }
1042
+ var SEGMENT = 408125543;
1043
+ var INFO = 357149030;
1044
+ var TIMECODE_SCALE = 2807729;
1045
+ var DURATION = 17545;
1046
+ function webmSeconds(b) {
1047
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
1048
+ for (let at = 0;at < b.length; ) {
1049
+ const id = ebmlId(b, at);
1050
+ const size = id && ebmlSize(b, id.next);
1051
+ if (!id || !size)
1052
+ return null;
1053
+ if (id.id === SEGMENT) {
1054
+ at = size.next;
1055
+ continue;
1056
+ }
1057
+ if (id.id === INFO && size.size !== null) {
1058
+ let scale = 1e6;
1059
+ let duration = null;
1060
+ const end = Math.min(size.next + size.size, b.length);
1061
+ for (let i = size.next;i < end; ) {
1062
+ const child = ebmlId(b, i);
1063
+ const childSize = child && ebmlSize(b, child.next);
1064
+ if (!child || !childSize || childSize.size === null)
1065
+ return null;
1066
+ const body = childSize.next;
1067
+ if (body + childSize.size > b.length)
1068
+ return null;
1069
+ if (child.id === TIMECODE_SCALE && childSize.size <= 6) {
1070
+ scale = 0;
1071
+ for (let j = 0;j < childSize.size; j++)
1072
+ scale = scale * 256 + b[body + j];
1073
+ } else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
1074
+ duration = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
1075
+ }
1076
+ i = body + childSize.size;
1077
+ }
1078
+ const seconds = duration !== null ? duration * scale / 1e9 : null;
1079
+ return seconds && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
1080
+ }
1081
+ if (size.size === null)
1082
+ return null;
1083
+ at = size.next + size.size;
1084
+ }
1085
+ return null;
1086
+ }
1087
+ function clipHeaderSeconds(bytes) {
1088
+ try {
1089
+ if (bytes.length >= 4 && bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163)
1090
+ return webmSeconds(bytes);
1091
+ return mp4Seconds(bytes);
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function jpegSize(b) {
1097
+ for (let at = 2;at + 9 <= b.length; ) {
1098
+ if (b[at] !== 255)
1099
+ return null;
1100
+ const marker = b[at + 1];
1101
+ if (marker === 255) {
1102
+ at += 1;
1103
+ continue;
1104
+ }
1105
+ if (marker === 1 || marker >= 208 && marker <= 215) {
1106
+ at += 2;
1107
+ continue;
1108
+ }
1109
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
1110
+ return { height: u16(b, at + 5), width: u16(b, at + 7) };
1111
+ }
1112
+ if (marker === 218 || marker === 217)
1113
+ return null;
1114
+ at += 2 + u16(b, at + 2);
1115
+ }
1116
+ return null;
1117
+ }
1118
+ function webpSize(b) {
1119
+ if (b.length < 30)
1120
+ return null;
1121
+ const chunk = ascii(b, 12, 4);
1122
+ const at = 20;
1123
+ if (chunk === "VP8 ") {
1124
+ if (b[at + 3] !== 157 || b[at + 4] !== 1 || b[at + 5] !== 42)
1125
+ return null;
1126
+ return { width: (b[at + 6] | b[at + 7] << 8) & 16383, height: (b[at + 8] | b[at + 9] << 8) & 16383 };
1127
+ }
1128
+ if (chunk === "VP8L") {
1129
+ if (b[at] !== 47)
1130
+ return null;
1131
+ const bits = (b[at + 1] | b[at + 2] << 8 | b[at + 3] << 16 | b[at + 4] << 24) >>> 0;
1132
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
1133
+ }
1134
+ if (chunk === "VP8X") {
1135
+ return { width: (b[at + 4] | b[at + 5] << 8 | b[at + 6] << 16) + 1, height: (b[at + 7] | b[at + 8] << 8 | b[at + 9] << 16) + 1 };
1136
+ }
1137
+ return null;
1138
+ }
1139
+ function pictureSize(bytes) {
1140
+ try {
1141
+ const b = bytes;
1142
+ if (b.length >= 24 && b[0] === 137 && ascii(b, 1, 3) === "PNG" && ascii(b, 12, 4) === "IHDR")
1143
+ return { width: u32(b, 16), height: u32(b, 20) };
1144
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255)
1145
+ return jpegSize(b);
1146
+ if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP")
1147
+ return webpSize(b);
1148
+ return null;
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+
976
1154
  // src/media.ts
977
1155
  var MEDIA_KINDS = ["thumbnail", "clip"];
978
1156
  var MEDIA_RULES = {
979
1157
  thumbnail: { maxBytes: 2 * 1024 * 1024, extensions: ["jpg", "jpeg", "png", "webp"], what: "a JPEG, PNG or WebP picture", bytes: isThumbnail },
980
1158
  clip: { maxBytes: 3 * 1024 * 1024, extensions: ["mp4", "webm"], what: "an MP4 or WebM video", bytes: isClip }
981
1159
  };
1160
+ var CLIP_MIN_SECONDS = 5;
1161
+ var CLIP_MAX_SECONDS = 10;
1162
+ var CLIP_SLACK_SECONDS = 0.25;
1163
+ function clipLengthProblem(seconds) {
1164
+ if (seconds < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
1165
+ return "short";
1166
+ if (seconds > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
1167
+ return "long";
1168
+ return null;
1169
+ }
1170
+ var MAX_PICTURE_SIDE = 4096;
982
1171
  function mediaPath(raw) {
983
1172
  return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
984
1173
  }
985
1174
  var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
986
- function mediaProblem(kind, path, size, head, missing = "is not among the files published") {
1175
+ function mediaProblem(kind, path, size, bytes, missing = "is not among the files published") {
987
1176
  const rule = MEDIA_RULES[kind];
988
1177
  const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
989
1178
  if (!rule.extensions.includes(extension))
@@ -991,10 +1180,23 @@ function mediaProblem(kind, path, size, head, missing = "is not among the files
991
1180
  if (size === undefined)
992
1181
  return `"${kind}" names ${path}, which ${missing}.`;
993
1182
  if (size > rule.maxBytes) {
994
- return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size)}.${kind === "clip" ? " Five seconds at 480p fits." : ""}`;
1183
+ return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size)}.${kind === "clip" ? ` A 480p clip of ${CLIP_MIN_SECONDS} to ${CLIP_MAX_SECONDS} seconds fits.` : ""}`;
995
1184
  }
996
- if (head && !rule.bytes(head))
1185
+ if (!bytes)
1186
+ return null;
1187
+ if (!rule.bytes(bytes))
997
1188
  return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
1189
+ if (kind === "clip") {
1190
+ const seconds = clipHeaderSeconds(bytes);
1191
+ if (seconds !== null && clipLengthProblem(seconds)) {
1192
+ return `${path} is ${seconds.toFixed(1)} seconds long. Keep your clip between ${CLIP_MIN_SECONDS} and ${CLIP_MAX_SECONDS} seconds.`;
1193
+ }
1194
+ } else {
1195
+ const picture = pictureSize(bytes);
1196
+ if (picture && (picture.width > MAX_PICTURE_SIDE || picture.height > MAX_PICTURE_SIDE)) {
1197
+ return `${path} is ${picture.width} by ${picture.height} pixels. Keep your thumbnail to ${MAX_PICTURE_SIDE} pixels or less on each side.`;
1198
+ }
1199
+ }
998
1200
  return null;
999
1201
  }
1000
1202
 
@@ -1105,8 +1307,8 @@ async function checkMedia(config, files) {
1105
1307
  continue;
1106
1308
  const path = mediaPath(raw);
1107
1309
  const file = files.find((f) => f.path === path);
1108
- const head = file ? await readHead(file.abs) : undefined;
1109
- const problem = mediaProblem(kind, path, file?.size, head, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
1310
+ const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile4(file.abs)) : await readHead(file.abs);
1311
+ const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
1110
1312
  if (problem)
1111
1313
  problems.push(problem);
1112
1314
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velven/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Publish a folder to Velven, the community marketplace for spaces built with AI: a private preview first, then hosted and live on its own page in seconds.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://velven.ai/docs",