@indigoai-us/hq-cli 5.345.153 → 5.345.155
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 +88 -0
- package/dist/command-catalog.generated.d.ts +54 -6
- package/dist/command-catalog.generated.js +69 -6
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +92 -3
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +48 -1
- package/dist/commands/lanes.js +7 -1
- package/dist/commands/library-add.d.ts +6 -2
- package/dist/commands/library-add.js +45 -6
- package/dist/commands/library-caption.d.ts +38 -1
- package/dist/commands/library-caption.js +304 -3
- package/dist/commands/library-link.d.ts +7 -3
- package/dist/commands/library-link.js +10 -4
- package/dist/commands/library-remove.d.ts +122 -0
- package/dist/commands/library-remove.js +681 -0
- package/dist/commands/library.d.ts +26 -1
- package/dist/commands/library.js +101 -14
- package/dist/commands/secrets.js +72 -5
- package/dist/commands/storage-offload.d.ts +22 -4
- package/dist/commands/storage-offload.js +196 -2
- package/dist/commands/storage.js +5 -0
- package/dist/lib/daemon/machine-login.d.ts +20 -0
- package/dist/lib/daemon/machine-login.js +53 -8
- package/dist/lib/daemon/machine-token-identity.d.ts +34 -0
- package/dist/lib/daemon/machine-token-identity.js +123 -0
- package/dist/lib/lanes/registry-flag.d.ts +23 -2
- package/dist/lib/lanes/registry-flag.js +256 -36
- package/dist/lib/personal-secrets-audit.d.ts +31 -0
- package/dist/lib/personal-secrets-audit.js +45 -0
- package/dist/lib/sync/read-only-local-only.d.ts +45 -0
- package/dist/lib/sync/read-only-local-only.js +122 -0
- package/package.json +7 -7
|
@@ -29,10 +29,10 @@ import https from "node:https";
|
|
|
29
29
|
import path from "node:path";
|
|
30
30
|
import { Readable } from "node:stream";
|
|
31
31
|
import { formatCloudAssetBlock, formatCloudPlaceholder, isCoveredByAny, parseCloudPlaceholder, resolvePullScope, } from "@indigoai-us/hq-cloud";
|
|
32
|
-
import { PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
|
|
32
|
+
import { CONTENT_SHA256_METADATA_KEY, PLACEHOLDER_SUFFIX, companyOffloadKey, containedPath, durablePlaceholderFs, renderPointer, resolvePlaceholder, } from "./storage-offload.js";
|
|
33
33
|
import { formatPlanGateError, formatUpgradeRefusal, planGateErrorFromResponse, } from "../utils/plan-gate-error.js";
|
|
34
34
|
/** Metadata key hq-pro binds to `checksumSha256` (sync's content hash). */
|
|
35
|
-
const CONTENT_SHA256_META_KEY =
|
|
35
|
+
const CONTENT_SHA256_META_KEY = CONTENT_SHA256_METADATA_KEY;
|
|
36
36
|
const MEDIA_TYPES = {
|
|
37
37
|
".png": "image/png",
|
|
38
38
|
".jpg": "image/jpeg",
|
|
@@ -42,6 +42,7 @@ const MEDIA_TYPES = {
|
|
|
42
42
|
".svg": "image/svg+xml",
|
|
43
43
|
".avif": "image/avif",
|
|
44
44
|
".heic": "image/heic",
|
|
45
|
+
".heif": "image/heif",
|
|
45
46
|
".mp4": "video/mp4",
|
|
46
47
|
".mov": "video/quicktime",
|
|
47
48
|
".webm": "video/webm",
|
|
@@ -136,6 +137,41 @@ export function resolveLibraryDir(hqRoot, options) {
|
|
|
136
137
|
}
|
|
137
138
|
return { slug, dirRel, dirAbs };
|
|
138
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The company and library folder for `add`. `--company` may be a slug or a
|
|
142
|
+
* cmp_ uid; the folder always uses the company's slug, so a uid never becomes
|
|
143
|
+
* a companies/cmp_…/ folder. A slug that disagrees with `--to` is refused
|
|
144
|
+
* before any lookup, as it always was.
|
|
145
|
+
*/
|
|
146
|
+
async function resolveLibraryTarget(options, deps) {
|
|
147
|
+
const companyIsUid = options.company !== undefined && isCompanyUid(options.company);
|
|
148
|
+
if (!companyIsUid)
|
|
149
|
+
resolveLibraryDir(deps.hqRoot, options);
|
|
150
|
+
const folderSlug = options.to === undefined ? undefined : resolveLibraryDir(deps.hqRoot, { to: options.to }).slug;
|
|
151
|
+
const company = await deps.resolveCompany((options.company ?? folderSlug), folderSlug);
|
|
152
|
+
if (isCompanyUid(company.slug)) {
|
|
153
|
+
throw new Error(`No company folder is known for ${company.uid}; library folders are companies/<slug>/. ` +
|
|
154
|
+
"Pass --company <slug> or --to companies/<slug>/<dir>.");
|
|
155
|
+
}
|
|
156
|
+
if (folderSlug !== undefined && company.slug !== folderSlug) {
|
|
157
|
+
throw new Error(`--to is inside companies/${folderSlug}/, but --company ${options.company} is ${company.slug}.`);
|
|
158
|
+
}
|
|
159
|
+
return { company, ...resolveLibraryDir(deps.hqRoot, { company: company.slug, to: options.to }) };
|
|
160
|
+
}
|
|
161
|
+
function isCompanyUid(ref) {
|
|
162
|
+
return ref.startsWith("cmp_");
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Capabilities an API key lacks, from hq-pro's keyed-route 403
|
|
166
|
+
* (`{ error, granted, missing }`). Empty for any other response.
|
|
167
|
+
*/
|
|
168
|
+
function missingKeyCapabilities(status, body) {
|
|
169
|
+
if (status !== 403)
|
|
170
|
+
return [];
|
|
171
|
+
if (typeof body.missing === "string" && body.missing)
|
|
172
|
+
return [body.missing];
|
|
173
|
+
return Array.isArray(body.missing) ? body.missing.filter((c) => typeof c === "string") : [];
|
|
174
|
+
}
|
|
139
175
|
async function hashStream(stream) {
|
|
140
176
|
const hash = crypto.createHash("sha256");
|
|
141
177
|
let size = 0;
|
|
@@ -212,8 +248,7 @@ async function uploadVerified(source, digest, grant, deps) {
|
|
|
212
248
|
export async function runLibraryAdd(inputs, options, deps) {
|
|
213
249
|
if (inputs.length === 0)
|
|
214
250
|
throw new Error("Pass at least one file or URL.");
|
|
215
|
-
const { slug, dirRel, dirAbs } =
|
|
216
|
-
const company = await deps.resolveCompany(slug);
|
|
251
|
+
const { company, slug, dirRel, dirAbs } = await resolveLibraryTarget(options, deps);
|
|
217
252
|
const items = [];
|
|
218
253
|
const planned = [];
|
|
219
254
|
const claimed = new Set();
|
|
@@ -268,9 +303,13 @@ export async function runLibraryAdd(inputs, options, deps) {
|
|
|
268
303
|
}
|
|
269
304
|
const body = (await response.json().catch(() => ({})));
|
|
270
305
|
if (!response.ok) {
|
|
271
|
-
const
|
|
306
|
+
const missing = missingKeyCapabilities(response.status, body);
|
|
307
|
+
const msg = missing.length > 0
|
|
308
|
+
? `This API key cannot upload (missing ${missing.join(", ")}). Use a key made with ` +
|
|
309
|
+
"`hq api-keys create --preset library-agent --allow-upload`, or unset HQ_API_KEY and run signed in."
|
|
310
|
+
: `Could not get an upload URL: ${typeof body.error === "string" ? body.error : `HTTP ${response.status}`}`;
|
|
272
311
|
for (const p of planned)
|
|
273
|
-
p.item.error =
|
|
312
|
+
p.item.error = msg;
|
|
274
313
|
return { company: slug, items };
|
|
275
314
|
}
|
|
276
315
|
const rows = Array.isArray(body.results) ? body.results : [];
|
|
@@ -105,8 +105,45 @@ export declare function runLibraryDescribe(target: string, deps: LibraryDescribe
|
|
|
105
105
|
export type RunTool = (command: string, args: string[]) => Promise<{
|
|
106
106
|
stdout: string;
|
|
107
107
|
}>;
|
|
108
|
+
/**
|
|
109
|
+
* HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
|
|
110
|
+
* bytes are not a format Claude Code reads directly. AVIF (also a HEIF
|
|
111
|
+
* container) is not included.
|
|
112
|
+
*/
|
|
113
|
+
export declare function isHeifImage(head: Buffer, name: string): boolean;
|
|
114
|
+
/** Width and height from a JPEG's start-of-frame marker, or null. */
|
|
115
|
+
export declare function jpegDimensions(bytes: Buffer): {
|
|
116
|
+
width: number;
|
|
117
|
+
height: number;
|
|
118
|
+
} | null;
|
|
119
|
+
/** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
|
|
120
|
+
export declare function isUsableJpegFrame(bytes: Buffer): boolean;
|
|
121
|
+
/** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
|
|
122
|
+
export declare function jpegExifOrientation(bytes: Buffer): number;
|
|
123
|
+
/** The JPEG without its APP1 (EXIF and XMP) segments. */
|
|
124
|
+
export declare function stripJpegApp1(bytes: Buffer): Buffer;
|
|
125
|
+
interface TileGrid {
|
|
126
|
+
width: number;
|
|
127
|
+
height: number;
|
|
128
|
+
left: number;
|
|
129
|
+
top: number;
|
|
130
|
+
tiles: Array<{
|
|
131
|
+
stream: number;
|
|
132
|
+
x: number;
|
|
133
|
+
y: number;
|
|
134
|
+
}>;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The primary tile grid from `ffprobe -show_stream_groups -of json`: the
|
|
138
|
+
* "Tile Grid" group marked default, else the first one. Null when the output
|
|
139
|
+
* does not have the expected shape (older ffmpeg has no tile grid groups).
|
|
140
|
+
*/
|
|
141
|
+
export declare function primaryTileGrid(ffprobeJson: string): TileGrid | null;
|
|
142
|
+
/** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
|
|
143
|
+
export declare function tileGridFilter(grid: TileGrid): string;
|
|
108
144
|
/** Seek points at 10%, 35%, 60% and 85% of a known duration. */
|
|
109
145
|
export declare function videoSeekPoints(durationSeconds: number | null): number[];
|
|
110
146
|
/** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
|
|
111
|
-
export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool): Promise<Buffer[]>;
|
|
147
|
+
export declare function extractCaptionFrames(file: string, mediaKind: CaptionMediaKind, run?: RunTool, platform?: NodeJS.Platform): Promise<Buffer[]>;
|
|
148
|
+
export {};
|
|
112
149
|
//# sourceMappingURL=library-caption.d.ts.map
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* Captioning runs on the user's machine through the local Claude Code CLI
|
|
6
6
|
* (`claude -p`, via the provider-launcher seam). No cloud caption service is
|
|
7
7
|
* called. Up to 4 frames are written to a private temp dir: an image is used
|
|
8
|
-
* as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise
|
|
9
|
-
* re-
|
|
8
|
+
* as-is when it is a png/jpeg/gif/webp under the frame limit, otherwise it is
|
|
9
|
+
* re-encoded to a 1024px JPEG. A HEIC/HEIF is decoded to its primary image
|
|
10
|
+
* with sips (macOS), ImageMagick, or ffmpeg's tile grid; any other image, and
|
|
11
|
+
* a HEIC none of those can decode, goes through ffmpeg's default stream. A
|
|
12
|
+
* video is sampled at up to 4 seek points,
|
|
10
13
|
* falling back to the first frame. Claude Code reads the frames with its Read
|
|
11
14
|
* tool only and replies with JSON { description, tags }.
|
|
12
15
|
*
|
|
@@ -339,6 +342,297 @@ async function jpegFrame(run, file, out, seconds) {
|
|
|
339
342
|
return null;
|
|
340
343
|
}
|
|
341
344
|
}
|
|
345
|
+
// ── HEIC / HEIF primary image ───────────────────────────────────────────────
|
|
346
|
+
/** ftyp brands of HEVC-coded HEIF images (HEIC) and generic HEIF image files. */
|
|
347
|
+
const HEIF_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs", "mif1", "msf1", "mif2"]);
|
|
348
|
+
/** HEVC-specific brands: these mark a file as HEIC even when it also lists AVIF brands. */
|
|
349
|
+
const HEVC_BRANDS = new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "hevm", "hevs"]);
|
|
350
|
+
const AVIF_BRANDS = new Set(["avif", "avis"]);
|
|
351
|
+
const HEIF_EXTENSIONS = new Set([".heic", ".heif", ".hif"]);
|
|
352
|
+
/** Bytes read to sniff the ftyp box. */
|
|
353
|
+
const HEIF_SNIFF_BYTES = 64;
|
|
354
|
+
/** A decoded frame smaller than this on either edge is not a usable picture. */
|
|
355
|
+
const MIN_FRAME_EDGE_PX = 16;
|
|
356
|
+
/** The brands in a leading ISO BMFF `ftyp` box, or null when there is none. */
|
|
357
|
+
function ftypBrands(head) {
|
|
358
|
+
if (head.length < 16 || head.subarray(4, 8).toString("latin1") !== "ftyp")
|
|
359
|
+
return null;
|
|
360
|
+
const boxSize = head.readUInt32BE(0);
|
|
361
|
+
const end = Math.min(head.length, boxSize >= 16 ? boxSize : head.length);
|
|
362
|
+
const brands = [head.subarray(8, 12).toString("latin1")];
|
|
363
|
+
for (let at = 16; at + 4 <= end; at += 4)
|
|
364
|
+
brands.push(head.subarray(at, at + 4).toString("latin1"));
|
|
365
|
+
return brands;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* HEIC / HEIF by content, or by a .heic/.heif/.hif name (any case) when the
|
|
369
|
+
* bytes are not a format Claude Code reads directly. AVIF (also a HEIF
|
|
370
|
+
* container) is not included.
|
|
371
|
+
*/
|
|
372
|
+
export function isHeifImage(head, name) {
|
|
373
|
+
const brands = ftypBrands(head);
|
|
374
|
+
if (brands) {
|
|
375
|
+
if (brands.some((brand) => HEVC_BRANDS.has(brand)))
|
|
376
|
+
return true;
|
|
377
|
+
if (brands.some((brand) => AVIF_BRANDS.has(brand)))
|
|
378
|
+
return false;
|
|
379
|
+
if (brands.some((brand) => HEIF_BRANDS.has(brand)))
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
return HEIF_EXTENSIONS.has(path.extname(name).toLowerCase()) && !isCaptionableImage(head);
|
|
383
|
+
}
|
|
384
|
+
/** Width and height from a JPEG's start-of-frame marker, or null. */
|
|
385
|
+
export function jpegDimensions(bytes) {
|
|
386
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
387
|
+
return null;
|
|
388
|
+
let at = 2;
|
|
389
|
+
while (at + 4 <= bytes.length) {
|
|
390
|
+
if (bytes[at] !== 0xff)
|
|
391
|
+
return null;
|
|
392
|
+
const marker = bytes[at + 1];
|
|
393
|
+
if (marker === 0xff) {
|
|
394
|
+
at += 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
398
|
+
at += 2;
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
if (marker === 0xd9 || marker === 0xda)
|
|
402
|
+
return null;
|
|
403
|
+
const length = bytes.readUInt16BE(at + 2);
|
|
404
|
+
if (length < 2)
|
|
405
|
+
return null;
|
|
406
|
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
407
|
+
if (isSof) {
|
|
408
|
+
if (at + 9 > bytes.length)
|
|
409
|
+
return null;
|
|
410
|
+
return { height: bytes.readUInt16BE(at + 5), width: bytes.readUInt16BE(at + 7) };
|
|
411
|
+
}
|
|
412
|
+
at += 2 + length;
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
/** A decoded frame is a non-empty JPEG within the frame byte and edge limits. */
|
|
417
|
+
export function isUsableJpegFrame(bytes) {
|
|
418
|
+
if (bytes.length === 0 || bytes.length > CAPTION_MAX_FRAME_BYTES)
|
|
419
|
+
return false;
|
|
420
|
+
if (!(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff))
|
|
421
|
+
return false;
|
|
422
|
+
const size = jpegDimensions(bytes);
|
|
423
|
+
if (!size)
|
|
424
|
+
return false;
|
|
425
|
+
const edges = [size.width, size.height];
|
|
426
|
+
return edges.every((edge) => edge >= MIN_FRAME_EDGE_PX && edge <= FRAME_EDGE_PX);
|
|
427
|
+
}
|
|
428
|
+
/** Run one decoder that writes `out`, and keep its output only when it is a usable JPEG. */
|
|
429
|
+
async function decodedFrame(run, command, args, out) {
|
|
430
|
+
try {
|
|
431
|
+
await fs.promises.rm(out, { force: true });
|
|
432
|
+
await run(command, args);
|
|
433
|
+
const bytes = await fs.promises.readFile(out);
|
|
434
|
+
return isUsableJpegFrame(bytes) ? bytes : null;
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** The EXIF orientation (1-8) in a JPEG's APP1 segment, or 1 when there is none. */
|
|
441
|
+
export function jpegExifOrientation(bytes) {
|
|
442
|
+
for (const segment of jpegSegments(bytes)) {
|
|
443
|
+
if (segment.marker !== 0xe1)
|
|
444
|
+
continue;
|
|
445
|
+
const body = bytes.subarray(segment.start + 4, segment.end);
|
|
446
|
+
if (body.length < 14 || body.subarray(0, 6).toString("latin1") !== "Exif\0\0")
|
|
447
|
+
continue;
|
|
448
|
+
const tiff = body.subarray(6);
|
|
449
|
+
const order = tiff.subarray(0, 2).toString("latin1");
|
|
450
|
+
if (order !== "II" && order !== "MM")
|
|
451
|
+
continue;
|
|
452
|
+
const u16 = (at) => (order === "II" ? tiff.readUInt16LE(at) : tiff.readUInt16BE(at));
|
|
453
|
+
const u32 = (at) => (order === "II" ? tiff.readUInt32LE(at) : tiff.readUInt32BE(at));
|
|
454
|
+
const ifd = u32(4);
|
|
455
|
+
if (ifd + 2 > tiff.length)
|
|
456
|
+
continue;
|
|
457
|
+
const count = u16(ifd);
|
|
458
|
+
for (let i = 0; i < count; i++) {
|
|
459
|
+
const entry = ifd + 2 + i * 12;
|
|
460
|
+
if (entry + 12 > tiff.length)
|
|
461
|
+
break;
|
|
462
|
+
if (u16(entry) === 0x0112) {
|
|
463
|
+
const value = u16(entry + 8);
|
|
464
|
+
return value >= 1 && value <= 8 ? value : 1;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
/** Marker segments before the scan: `start` is the 0xFF byte, `end` is past the segment. */
|
|
471
|
+
function jpegSegments(bytes) {
|
|
472
|
+
const segments = [];
|
|
473
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
|
474
|
+
return segments;
|
|
475
|
+
let at = 2;
|
|
476
|
+
while (at + 4 <= bytes.length && bytes[at] === 0xff) {
|
|
477
|
+
const marker = bytes[at + 1];
|
|
478
|
+
if (marker === 0xda || marker === 0xd9)
|
|
479
|
+
break;
|
|
480
|
+
const length = bytes.readUInt16BE(at + 2);
|
|
481
|
+
if (length < 2 || at + 2 + length > bytes.length)
|
|
482
|
+
break;
|
|
483
|
+
segments.push({ marker, start: at, end: at + 2 + length });
|
|
484
|
+
at += 2 + length;
|
|
485
|
+
}
|
|
486
|
+
return segments;
|
|
487
|
+
}
|
|
488
|
+
/** The JPEG without its APP1 (EXIF and XMP) segments. */
|
|
489
|
+
export function stripJpegApp1(bytes) {
|
|
490
|
+
const drop = jpegSegments(bytes).filter((segment) => segment.marker === 0xe1);
|
|
491
|
+
if (drop.length === 0)
|
|
492
|
+
return bytes;
|
|
493
|
+
const parts = [];
|
|
494
|
+
let at = 0;
|
|
495
|
+
for (const segment of drop) {
|
|
496
|
+
parts.push(bytes.subarray(at, segment.start));
|
|
497
|
+
at = segment.end;
|
|
498
|
+
}
|
|
499
|
+
parts.push(bytes.subarray(at));
|
|
500
|
+
return Buffer.concat(parts);
|
|
501
|
+
}
|
|
502
|
+
/** sips steps that turn stored pixels upright for each EXIF orientation (rotations are clockwise). */
|
|
503
|
+
const SIPS_UPRIGHT = {
|
|
504
|
+
2: [["-f", "horizontal"]],
|
|
505
|
+
3: [["-r", "180"]],
|
|
506
|
+
4: [["-f", "vertical"]],
|
|
507
|
+
5: [["-r", "90"], ["-f", "horizontal"]],
|
|
508
|
+
6: [["-r", "90"]],
|
|
509
|
+
7: [["-r", "90"], ["-f", "vertical"]],
|
|
510
|
+
8: [["-r", "270"]],
|
|
511
|
+
};
|
|
512
|
+
/**
|
|
513
|
+
* sips keeps the stored pixel order and copies the EXIF orientation, so a
|
|
514
|
+
* HEIC shot upside down or sideways decodes rotated. Rotate the pixels
|
|
515
|
+
* upright with sips and drop the EXIF/XMP segments so nothing rotates them
|
|
516
|
+
* again. When a rotation step fails, the frame is kept with its EXIF intact.
|
|
517
|
+
*/
|
|
518
|
+
async function uprightSipsFrame(run, frame, out) {
|
|
519
|
+
const steps = SIPS_UPRIGHT[jpegExifOrientation(frame)];
|
|
520
|
+
if (!steps)
|
|
521
|
+
return stripJpegApp1(frame);
|
|
522
|
+
try {
|
|
523
|
+
for (const step of steps)
|
|
524
|
+
await run("sips", [...step, out]);
|
|
525
|
+
const rotated = await fs.promises.readFile(out);
|
|
526
|
+
const upright = stripJpegApp1(rotated);
|
|
527
|
+
return isUsableJpegFrame(upright) ? upright : frame;
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return frame;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function nonNegativeInt(value) {
|
|
534
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* The primary tile grid from `ffprobe -show_stream_groups -of json`: the
|
|
538
|
+
* "Tile Grid" group marked default, else the first one. Null when the output
|
|
539
|
+
* does not have the expected shape (older ffmpeg has no tile grid groups).
|
|
540
|
+
*/
|
|
541
|
+
export function primaryTileGrid(ffprobeJson) {
|
|
542
|
+
let parsed;
|
|
543
|
+
try {
|
|
544
|
+
parsed = JSON.parse(ffprobeJson);
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
const groups = parsed.stream_groups;
|
|
550
|
+
if (!Array.isArray(groups))
|
|
551
|
+
return null;
|
|
552
|
+
const grids = groups.filter((g) => !!g && g.type === "Tile Grid");
|
|
553
|
+
const group = grids.find((g) => g.disposition?.default === 1) ?? grids[0];
|
|
554
|
+
const component = Array.isArray(group?.components) ? group.components[0] : undefined;
|
|
555
|
+
if (!component || !Array.isArray(component.subcomponents))
|
|
556
|
+
return null;
|
|
557
|
+
const width = nonNegativeInt(component.width);
|
|
558
|
+
const height = nonNegativeInt(component.height);
|
|
559
|
+
const left = nonNegativeInt(component.horizontal_offset) ?? 0;
|
|
560
|
+
const top = nonNegativeInt(component.vertical_offset) ?? 0;
|
|
561
|
+
if (!width || !height)
|
|
562
|
+
return null;
|
|
563
|
+
const tiles = [];
|
|
564
|
+
for (const sub of component.subcomponents) {
|
|
565
|
+
const stream = nonNegativeInt(sub?.stream_index);
|
|
566
|
+
const x = nonNegativeInt(sub?.tile_horizontal_offset);
|
|
567
|
+
const y = nonNegativeInt(sub?.tile_vertical_offset);
|
|
568
|
+
if (stream === null || x === null || y === null)
|
|
569
|
+
return null;
|
|
570
|
+
tiles.push({ stream, x, y });
|
|
571
|
+
}
|
|
572
|
+
return tiles.length > 0 ? { width, height, left, top, tiles } : null;
|
|
573
|
+
}
|
|
574
|
+
/** ffmpeg filter graph that assembles a tile grid, crops it and scales it to the frame size. */
|
|
575
|
+
export function tileGridFilter(grid) {
|
|
576
|
+
const inputs = grid.tiles.map((tile) => `[0:${tile.stream}]`).join("");
|
|
577
|
+
const crop = `crop=${grid.width}:${grid.height}:${grid.left}:${grid.top}`;
|
|
578
|
+
if (grid.tiles.length === 1)
|
|
579
|
+
return `${inputs}${crop},${SCALE_FILTER}[frame]`;
|
|
580
|
+
const layout = grid.tiles.map((tile) => `${tile.x}_${tile.y}`).join("|");
|
|
581
|
+
return `${inputs}xstack=inputs=${grid.tiles.length}:layout=${layout}:fill=black,${crop},${SCALE_FILTER}[frame]`;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The primary image of a HEIC / HEIF as a 1024px JPEG. ffmpeg's default video
|
|
585
|
+
* stream in an iPhone HEIC is a single tile or, for Portrait photos, the
|
|
586
|
+
* depth/segmentation matte, so the primary image is decoded with sips
|
|
587
|
+
* (macOS), then ImageMagick (libheif), then by assembling ffmpeg's primary
|
|
588
|
+
* tile grid. Null when none of them produced a usable frame.
|
|
589
|
+
*/
|
|
590
|
+
async function heifPrimaryFrame(run, file, dir, platform) {
|
|
591
|
+
const out = path.join(dir, "primary.jpg");
|
|
592
|
+
const edge = String(FRAME_EDGE_PX);
|
|
593
|
+
if (platform === "darwin") {
|
|
594
|
+
const frame = await decodedFrame(run, "sips", ["-s", "format", "jpeg", "-s", "formatOptions", "80", "-Z", edge, file, "--out", out], out);
|
|
595
|
+
if (frame)
|
|
596
|
+
return uprightSipsFrame(run, frame, out);
|
|
597
|
+
}
|
|
598
|
+
// The explicit heic: coder and [0] make ImageMagick decode only the primary image as HEIF.
|
|
599
|
+
const imArgs = [`heic:${file}[0]`, "-auto-orient", "-resize", `${edge}x${edge}>`, "-quality", "80", out];
|
|
600
|
+
const magick = await decodedFrame(run, "magick", imArgs, out);
|
|
601
|
+
if (magick)
|
|
602
|
+
return magick;
|
|
603
|
+
// On Windows `convert` is the system disk-conversion tool, not ImageMagick.
|
|
604
|
+
if (platform !== "win32") {
|
|
605
|
+
const convert = await decodedFrame(run, "convert", imArgs, out);
|
|
606
|
+
if (convert)
|
|
607
|
+
return convert;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
const { stdout } = await run("ffprobe", ["-v", "error", "-show_stream_groups", "-of", "json", file]);
|
|
611
|
+
const grid = primaryTileGrid(stdout);
|
|
612
|
+
if (grid) {
|
|
613
|
+
const args = ["-v", "error", "-y", "-i", file, "-filter_complex", tileGridFilter(grid), "-map", "[frame]",
|
|
614
|
+
"-frames:v", "1", "-q:v", "4", "-update", "1", out];
|
|
615
|
+
const frame = await decodedFrame(run, "ffmpeg", args, out);
|
|
616
|
+
if (frame)
|
|
617
|
+
return frame;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
// No ffprobe, or one without stream groups: fall back to ffmpeg's default stream.
|
|
622
|
+
}
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
async function readHead(file) {
|
|
626
|
+
const handle = await fs.promises.open(file, "r");
|
|
627
|
+
try {
|
|
628
|
+
const head = Buffer.alloc(HEIF_SNIFF_BYTES);
|
|
629
|
+
const { bytesRead } = await handle.read(head, 0, HEIF_SNIFF_BYTES, 0);
|
|
630
|
+
return head.subarray(0, bytesRead);
|
|
631
|
+
}
|
|
632
|
+
finally {
|
|
633
|
+
await handle.close();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
342
636
|
/** Seek points at 10%, 35%, 60% and 85% of a known duration. */
|
|
343
637
|
export function videoSeekPoints(durationSeconds) {
|
|
344
638
|
if (durationSeconds === null || !Number.isFinite(durationSeconds) || durationSeconds <= 0)
|
|
@@ -346,7 +640,8 @@ export function videoSeekPoints(durationSeconds) {
|
|
|
346
640
|
return [0.1, 0.35, 0.6, 0.85].map((share) => durationSeconds * share);
|
|
347
641
|
}
|
|
348
642
|
/** Frames for captioning: the image itself when it fits, otherwise ffmpeg JPEGs. */
|
|
349
|
-
export async function extractCaptionFrames(file, mediaKind, run = runTool) {
|
|
643
|
+
export async function extractCaptionFrames(file, mediaKind, run = runTool, platform = process.platform) {
|
|
644
|
+
let heif = false;
|
|
350
645
|
if (mediaKind === "image") {
|
|
351
646
|
const stat = await fs.promises.stat(file);
|
|
352
647
|
if (stat.size <= CAPTION_MAX_FRAME_BYTES) {
|
|
@@ -354,9 +649,15 @@ export async function extractCaptionFrames(file, mediaKind, run = runTool) {
|
|
|
354
649
|
if (isCaptionableImage(bytes))
|
|
355
650
|
return [bytes];
|
|
356
651
|
}
|
|
652
|
+
heif = isHeifImage(await readHead(file), file);
|
|
357
653
|
}
|
|
358
654
|
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "hq-frames-"));
|
|
359
655
|
try {
|
|
656
|
+
if (heif) {
|
|
657
|
+
const primary = await heifPrimaryFrame(run, file, dir, platform);
|
|
658
|
+
if (primary)
|
|
659
|
+
return [primary];
|
|
660
|
+
}
|
|
360
661
|
if (mediaKind === "image") {
|
|
361
662
|
const frame = await jpegFrame(run, file, path.join(dir, "image.jpg"));
|
|
362
663
|
return frame ? [frame] : [];
|
|
@@ -10,10 +10,14 @@ export interface LibraryLinkOptions {
|
|
|
10
10
|
export interface LibraryLinkDeps {
|
|
11
11
|
/** HQ root; only called for a placeholder target. */
|
|
12
12
|
hqRoot: () => string;
|
|
13
|
-
/** True when the call runs under HQ_API_KEY (
|
|
13
|
+
/** True when the call runs under HQ_API_KEY (no membership lookup). */
|
|
14
14
|
apiKey: boolean;
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Company ref (slug or uid, or undefined for the default) → cmp_ uid. Under
|
|
17
|
+
* a key, `placeholderSlug` is the placeholder's companies/<slug>/, a
|
|
18
|
+
* fallback when neither --company nor HQ_COMPANY_UID names the company.
|
|
19
|
+
*/
|
|
20
|
+
resolveCompany: (ref: string | undefined, placeholderSlug?: string) => Promise<string>;
|
|
17
21
|
/** POST /v1/files/presign. */
|
|
18
22
|
presign: (body: Record<string, unknown>) => Promise<Response>;
|
|
19
23
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* Runs on a Cognito session or on an `HQ_API_KEY` with `files:read` (the
|
|
7
7
|
* library-agent preset). Under a key, vaultApiFetch rewrites /v1/files/presign
|
|
8
8
|
* to /v1/keys/files/presign; the key is company-bound, so the company comes
|
|
9
|
-
* from --company
|
|
9
|
+
* from --company (a cmp_ uid, or a slug mapped through companies/manifest.yaml),
|
|
10
|
+
* HQ_COMPANY_UID, or the placeholder's companies/<slug>/ via the manifest.
|
|
10
11
|
*
|
|
11
12
|
* Wire contract (hq-pro files-presign):
|
|
12
13
|
* POST /v1/files/presign
|
|
@@ -44,6 +45,7 @@ export async function runLibraryLink(target, options, deps) {
|
|
|
44
45
|
let key;
|
|
45
46
|
let placeholderPath;
|
|
46
47
|
let companyRef = options.company;
|
|
48
|
+
let placeholderSlug;
|
|
47
49
|
if (isVaultKey(target)) {
|
|
48
50
|
key = target;
|
|
49
51
|
}
|
|
@@ -63,14 +65,18 @@ export async function runLibraryLink(target, options, deps) {
|
|
|
63
65
|
}
|
|
64
66
|
key = placeholder.key;
|
|
65
67
|
// On a session the placeholder's companies/<slug>/ names the company. A
|
|
66
|
-
// key is company-bound
|
|
68
|
+
// key is company-bound: its resolver prefers HQ_COMPANY_UID and gets the
|
|
69
|
+
// slug only as a fallback.
|
|
70
|
+
placeholderSlug = /^companies\/([^/]+)\//.exec(placeholderPath)?.[1];
|
|
67
71
|
if (companyRef === undefined && !deps.apiKey) {
|
|
68
|
-
companyRef =
|
|
72
|
+
companyRef = placeholderSlug;
|
|
69
73
|
if (!companyRef)
|
|
70
74
|
throw new Error(`'${placeholderPath}' is not inside companies/<slug>/; pass --company.`);
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
|
-
const company =
|
|
77
|
+
const company = deps.apiKey && placeholderSlug !== undefined
|
|
78
|
+
? await deps.resolveCompany(companyRef, placeholderSlug)
|
|
79
|
+
: await deps.resolveCompany(companyRef);
|
|
74
80
|
const res = await deps.presign({ company, key, op: "get", expiresIn });
|
|
75
81
|
const body = (await res.json().catch(() => ({})));
|
|
76
82
|
if (!res.ok) {
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
export interface LibraryRemoveOptions {
|
|
3
|
+
/** Company slug (or cmp_ uid); every target must be in this company. */
|
|
4
|
+
company?: string;
|
|
5
|
+
purge?: boolean;
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
yes?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Server reply for POST /v1/files/delete (the fields rm reads). */
|
|
10
|
+
export interface VaultDeleteReply {
|
|
11
|
+
matched: number;
|
|
12
|
+
deleted: number;
|
|
13
|
+
}
|
|
14
|
+
export interface LibraryRemoveDeps {
|
|
15
|
+
hqRoot: string;
|
|
16
|
+
/** Company slug → cmp_ uid. */
|
|
17
|
+
resolveCompany: (slug: string) => Promise<string>;
|
|
18
|
+
/**
|
|
19
|
+
* POST /v1/files/delete for one exact key. Throws an Error whose message is
|
|
20
|
+
* ready to show (permission and auth errors included).
|
|
21
|
+
*/
|
|
22
|
+
deleteKey: (companyUid: string, key: string, dryRun: boolean) => Promise<VaultDeleteReply>;
|
|
23
|
+
/** POST /v1/search. Only called for `--purge`. */
|
|
24
|
+
searchAssets: (body: Record<string, unknown>) => Promise<Response>;
|
|
25
|
+
/** Every key in the company vault (company-relative). Only called for `--purge`. */
|
|
26
|
+
listVaultKeys: (companyUid: string) => Promise<string[]>;
|
|
27
|
+
/** Text of small vault objects; null for one that cannot be read. Only called for `--purge`. */
|
|
28
|
+
readVaultObjects: (companyUid: string, keys: readonly string[]) => Promise<Map<string, string | null>>;
|
|
29
|
+
/** Ask a yes/no question. Only called without --yes and --dry-run. */
|
|
30
|
+
confirm: (message: string) => Promise<boolean>;
|
|
31
|
+
/** Preview and progress lines. */
|
|
32
|
+
log: (line: string) => void;
|
|
33
|
+
/** sha256 of a local file. */
|
|
34
|
+
hashFile?: (file: string) => Promise<string>;
|
|
35
|
+
/** Delete one local file. */
|
|
36
|
+
removeFile?: (file: string) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export type StepState = "removed" | "would-remove" | "absent" | "kept" | "failed" | "not-run";
|
|
39
|
+
export interface LibraryRemoveItem {
|
|
40
|
+
/** HQ-relative placeholder path. */
|
|
41
|
+
placeholderPath: string;
|
|
42
|
+
/** HQ-relative original path (the file the placeholder stands in for). */
|
|
43
|
+
originalPath: string;
|
|
44
|
+
/** Company-relative vault key of the synced placeholder. */
|
|
45
|
+
vaultKey: string;
|
|
46
|
+
/** The blob the placeholder points at. */
|
|
47
|
+
blobKey: string;
|
|
48
|
+
original: StepState;
|
|
49
|
+
placeholder: StepState;
|
|
50
|
+
vault: StepState;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface LibraryRemoveBlob {
|
|
54
|
+
key: string;
|
|
55
|
+
state: StepState;
|
|
56
|
+
error?: string;
|
|
57
|
+
}
|
|
58
|
+
export interface LibraryRemoveResult {
|
|
59
|
+
company: string;
|
|
60
|
+
companyUid: string;
|
|
61
|
+
dryRun: boolean;
|
|
62
|
+
purge: boolean;
|
|
63
|
+
/** False when the confirmation was declined. */
|
|
64
|
+
confirmed: boolean;
|
|
65
|
+
items: LibraryRemoveItem[];
|
|
66
|
+
blobs: LibraryRemoveBlob[];
|
|
67
|
+
/** Commands that undo the vault side. */
|
|
68
|
+
undo: string[];
|
|
69
|
+
}
|
|
70
|
+
/** Local company placeholders, other than `exclude`, that point at `blobKey`. */
|
|
71
|
+
export declare function localBlobReferences(hqRoot: string, slug: string, blobKey: string, exclude: ReadonlySet<string>): string[];
|
|
72
|
+
/**
|
|
73
|
+
* Placeholders in the company vault, other than `exclude` (company-relative
|
|
74
|
+
* keys), that point at any of `blobKeys`. Returns blob key → placeholder keys.
|
|
75
|
+
*
|
|
76
|
+
* Two passes:
|
|
77
|
+
* 1. a quick asset search (`kind: "asset"`, query "hqcloud", which every
|
|
78
|
+
* placeholder path carries). Each hit returns its vaultKey, so a shared
|
|
79
|
+
* blob is usually found in one call. This pass cannot prove a blob is
|
|
80
|
+
* unused: hq-pro does not index the hashes and keys in a placeholder,
|
|
81
|
+
* caps a search at 50 hits with no paging, and may lag a fresh upload.
|
|
82
|
+
* Its errors are ignored; pass 2 decides.
|
|
83
|
+
* 2. when pass 1 finds nothing, every `.hqcloud` key the caller can list
|
|
84
|
+
* (GET /v1/files/list, as `hq files search` does), read in one batched
|
|
85
|
+
* presign and parsed. This lists the whole company vault, so it can take
|
|
86
|
+
* minutes on a large one. Any listing or read failure throws: a purge
|
|
87
|
+
* must not go ahead on a guess.
|
|
88
|
+
*/
|
|
89
|
+
export declare function cloudBlobReferences(blobKeys: readonly string[], slug: string, companyUid: string, exclude: ReadonlySet<string>, deps: Pick<LibraryRemoveDeps, "searchAssets" | "listVaultKeys" | "readVaultObjects" | "log">): Promise<Map<string, string[]>>;
|
|
90
|
+
/** GET /v1/files/list for every key in the company vault, following the cursor. */
|
|
91
|
+
export declare function listAllVaultKeys(companyUid: string, list: (query: Record<string, string>) => Promise<Response>): Promise<string[]>;
|
|
92
|
+
/**
|
|
93
|
+
* Read small vault objects: one batched presign (op "get") per 1000 keys, then
|
|
94
|
+
* a GET per URL. A key that cannot be presigned or read maps to null. Errors
|
|
95
|
+
* never include a URL, which carries a signature.
|
|
96
|
+
*/
|
|
97
|
+
export declare function readSmallVaultObjects(companyUid: string, keys: readonly string[], presign: (body: Record<string, unknown>) => Promise<Response>, get?: (url: string) => Promise<Response>): Promise<Map<string, string | null>>;
|
|
98
|
+
/**
|
|
99
|
+
* Testable core. Checks every target, previews, confirms, then removes in the
|
|
100
|
+
* order described at the top of this file.
|
|
101
|
+
*/
|
|
102
|
+
export declare function runLibraryRemove(targets: readonly string[], options: LibraryRemoveOptions, deps: LibraryRemoveDeps): Promise<LibraryRemoveResult>;
|
|
103
|
+
export declare function formatLibraryRemoveResult(result: LibraryRemoveResult): string;
|
|
104
|
+
/**
|
|
105
|
+
* User-facing message for a failed POST /v1/files/delete. `err` is duck-typed
|
|
106
|
+
* on `status` (files.ts's FilesDeleteHttpError) so this module does not load
|
|
107
|
+
* files.ts at startup.
|
|
108
|
+
*/
|
|
109
|
+
export declare function vaultDeleteErrorMessage(err: unknown, key: string, apiKey: boolean, format: (err: never, key: string) => string): string;
|
|
110
|
+
/**
|
|
111
|
+
* Company resolver for rm. A session resolves slugs through the API. An
|
|
112
|
+
* HQ_API_KEY cannot (hq-pro serves no keyed membership lookup), so it maps the
|
|
113
|
+
* placeholder folder's slug, or an explicit `--company` slug or uid, through
|
|
114
|
+
* companies/manifest.yaml with library.ts's libraryCompanyForApiKey; hq-pro
|
|
115
|
+
* still checks the uid against the key on every delete.
|
|
116
|
+
*/
|
|
117
|
+
export declare function removeCompanyResolver(credential: {
|
|
118
|
+
kind: "api-key" | "cognito";
|
|
119
|
+
token: string;
|
|
120
|
+
}, hqRoot: string, explicit: string | undefined): Promise<(slug: string) => Promise<string>>;
|
|
121
|
+
export declare function registerLibraryRemoveCommand(library: Command): void;
|
|
122
|
+
//# sourceMappingURL=library-remove.d.ts.map
|