@hubfluencer/mcp 0.1.0 → 0.3.0

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/src/index.ts CHANGED
@@ -35,9 +35,11 @@ import {
35
35
  normalizeStatus,
36
36
  resolveSavePath,
37
37
  } from "./core.js";
38
+ import { runLogin } from "./login.js";
38
39
  import {
39
40
  IMAGE_EXTS,
40
41
  uploadImageFile,
42
+ uploadShortPoster,
41
43
  uploadVideoFile,
42
44
  VIDEO_EXTS,
43
45
  } from "./uploads.js";
@@ -322,7 +324,7 @@ const WRITE = {
322
324
  openWorldHint: true,
323
325
  };
324
326
 
325
- const server = new McpServer({ name: "hubfluencer", version: "0.1.0" });
327
+ const server = new McpServer({ name: "hubfluencer", version: "0.3.0" });
326
328
 
327
329
  // The SDK's `registerTool` is generic over the Zod input shape; with this many
328
330
  // tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
@@ -423,7 +425,12 @@ registerTool(
423
425
  "WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " +
424
426
  "spend. kind defaults to 'auto' — a multi-scene editor ad for ad/promo/story briefs, a single-clip short " +
425
427
  "for simple/short ones; the chosen kind is reported back as kind_inferred. If it returns terminal=false " +
426
- "the render is still running — call wait_for_completion with the returned slug to finish.",
428
+ "the render is still running — call wait_for_completion with the returned slug to finish. " +
429
+ "BE PROACTIVE WITH BRANDING: pass headline (the on-screen TITLE) and subheadline (secondary title) plus " +
430
+ "music_vibe/theme so a one-shot short isn't bare — these apply to SHORTS (music_vibe/headline/subheadline " +
431
+ "are ignored for editor; theme applies to both). For richer branding — a product image, brand logo, or " +
432
+ "closing card — drive the granular path instead: create_short + set_short_product/set_short_poster, or " +
433
+ "create_editor_draft + set_product/set_logo/set_closing_image, then start_autopilot/generate_short.",
427
434
  // Schema kept intentionally flat (no .min/.max/.int chains) — the SDK's
428
435
  // generic inference on registerTool hits TS2589 ("excessively deep") on
429
436
  // larger schemas with chained validators. Ranges are enforced in code.
@@ -451,6 +458,30 @@ registerTool(
451
458
  .describe(
452
459
  "Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover",
453
460
  ),
461
+ headline: z
462
+ .string()
463
+ .optional()
464
+ .describe(
465
+ "SHORTS only: the on-screen TITLE overlay (≤160). Set this so the short isn't bare.",
466
+ ),
467
+ subheadline: z
468
+ .string()
469
+ .optional()
470
+ .describe("SHORTS only: the secondary title / supporting line (≤200)"),
471
+ music_vibe: z
472
+ .string()
473
+ .optional()
474
+ .describe(
475
+ "SHORTS only: music mood — Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz",
476
+ ),
477
+ theme: z
478
+ .string()
479
+ .optional()
480
+ .describe(
481
+ "Visual theme (both kinds): none (literal — no imposed style), realistic (default), cinematic, " +
482
+ "anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, " +
483
+ "minimalist, cyberpunk",
484
+ ),
454
485
  save_path: z
455
486
  .string()
456
487
  .optional()
@@ -485,6 +516,10 @@ registerTool(
485
516
  language?: string;
486
517
  aspect?: string;
487
518
  voice_id?: string;
519
+ headline?: string;
520
+ subheadline?: string;
521
+ music_vibe?: string;
522
+ theme?: string;
488
523
  save_path?: string;
489
524
  max_wait_seconds?: number;
490
525
  dry_run?: boolean;
@@ -514,13 +549,31 @@ registerTool(
514
549
  // make_video (or a dry_run then a real run) reuses this same draft.
515
550
  let slug: string;
516
551
  if (kind === "short") {
552
+ // Carry the creative fields inline so a one-shot short can be titled
553
+ // and branded (headline/subheadline/music_vibe are short-only; theme
554
+ // applies to both kinds).
517
555
  const created = await client.post<{ data: { slug: string } }>(
518
556
  "/shorts",
519
- { product_prompt: args.prompt, language: args.language },
557
+ {
558
+ product_prompt: args.prompt,
559
+ language: args.language,
560
+ headline: args.headline,
561
+ subheadline: args.subheadline,
562
+ music_vibe: args.music_vibe,
563
+ theme: args.theme,
564
+ },
520
565
  // Fold every create-affecting param into the key: two calls that
521
- // differ only in language must NOT dedup to the same draft, and a
522
- // transport retry of an identical call must reuse it.
523
- idemKey("make-short", args.prompt, args.language ?? ""),
566
+ // differ only in a creative field must NOT dedup to the same draft,
567
+ // and a transport retry of an identical call must reuse it.
568
+ idemKey(
569
+ "make-short",
570
+ args.prompt,
571
+ args.language ?? "",
572
+ args.headline ?? "",
573
+ args.subheadline ?? "",
574
+ args.music_vibe ?? "",
575
+ args.theme ?? "",
576
+ ),
524
577
  );
525
578
  slug = created.data.slug;
526
579
  } else {
@@ -531,6 +584,7 @@ registerTool(
531
584
  product_prompt: args.prompt,
532
585
  export_aspect_ratio: args.aspect,
533
586
  voice_id: args.voice_id,
587
+ theme: args.theme,
534
588
  },
535
589
  idemKey(
536
590
  "make-editor",
@@ -538,6 +592,7 @@ registerTool(
538
592
  args.language ?? "en",
539
593
  args.aspect ?? "",
540
594
  args.voice_id ?? "",
595
+ args.theme ?? "",
541
596
  ),
542
597
  );
543
598
  slug = created.data.slug;
@@ -676,7 +731,23 @@ registerTool(
676
731
  inputSchema: {},
677
732
  annotations: { title: "Get credits", ...RO },
678
733
  },
679
- tool(async (_args, client) => ok(await client.get("/studio/credits"))),
734
+ tool(async (_args, client) => {
735
+ const res = await client.get<{ data: unknown }>("/studio/credits");
736
+ const d = asRecord(asRecord(res).data);
737
+ // Whitelist generation-relevant fields only. Deliberately DROP
738
+ // `first_editor_video_pack_available`: it's a purchase-funnel flag for the
739
+ // PAID one-time starter pack (33 credits — not free), and this server has
740
+ // no billing scope, so it can't act on it. Passing it through only invited
741
+ // the agent to misread `true` as "your first video may be free", which
742
+ // contradicts the product (the first video is never free).
743
+ return ok({
744
+ credits: d.credits,
745
+ spendable_credits: d.spendable_credits,
746
+ reserved_credits: d.reserved_credits,
747
+ remaining_reserved_credits: d.remaining_reserved_credits,
748
+ message: d.message,
749
+ });
750
+ }),
680
751
  );
681
752
 
682
753
  // ── Voices ────────────────────────────────────────────────────────────────
@@ -769,8 +840,13 @@ registerTool(
769
840
  {
770
841
  title: "Create a short (draft)",
771
842
  description:
772
- "Creates a short draft from a product prompt (min 10 chars). Returns the slug. Costs 0 credits. " +
773
- "Follow with generate_short to render. Prefer make_video for the one-shot path.",
843
+ "Creates a short draft from a product prompt (min 10 chars). A short is a 12s vertical (two 6s AI " +
844
+ "segments + an on-screen title overlay + music; 14s when an end-card poster is set). Returns the slug, " +
845
+ "costs 0 credits. Follow with generate_short to render. " +
846
+ "BE PROACTIVE: don't ship a bare clip — set a headline (the on-screen TITLE) and subheadline (secondary " +
847
+ "title), and pick a music_vibe and theme that fit the brand. To brand it further, attach a product image " +
848
+ "(set_short_product) or an end-card poster (set_short_poster) before generate_short. (Shorts have no logo " +
849
+ "overlay — that's an editor-only feature; use create_editor_ad for logo branding.)",
774
850
  inputSchema: {
775
851
  product_prompt: z
776
852
  .string()
@@ -780,21 +856,109 @@ registerTool(
780
856
  .string()
781
857
  .optional()
782
858
  .describe('Language code, e.g. "en" (default)'),
783
- theme: z.string().optional(),
784
- headline: z.string().optional(),
785
- subheadline: z.string().optional(),
786
- music_vibe: z.string().optional(),
859
+ headline: z
860
+ .string()
861
+ .max(160)
862
+ .optional()
863
+ .describe(
864
+ "The on-screen TITLE composited over the short (poster text overlay). ≤160 chars. Set this.",
865
+ ),
866
+ subheadline: z
867
+ .string()
868
+ .max(200)
869
+ .optional()
870
+ .describe(
871
+ "The SECONDARY title / supporting line under the headline. ≤200 chars.",
872
+ ),
873
+ theme: z
874
+ .string()
875
+ .optional()
876
+ .describe(
877
+ "Visual theme (default realistic). One of: none (no imposed style — follows the prompt literally), " +
878
+ "realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, " +
879
+ "gaming, retro_80s, minimalist, cyberpunk.",
880
+ ),
881
+ music_vibe: z
882
+ .string()
883
+ .optional()
884
+ .describe(
885
+ "Background-music mood. Recognized: Upbeat (default), Cinematic, Minimal, Luxury, Playful, Jazz.",
886
+ ),
887
+ music_instruments: z
888
+ .array(z.string())
889
+ .optional()
890
+ .describe('Optional instrument hints, e.g. ["piano", "strings"]'),
891
+ text_position: z
892
+ .enum(["top", "center", "bottom"])
893
+ .optional()
894
+ .describe("Where the title overlay sits (default bottom)"),
895
+ text_animation: z
896
+ .enum(["reveal", "typewriter", "fade_in", "pop", "bounce"])
897
+ .optional()
898
+ .describe("Title overlay animation (default fade_in)"),
899
+ font_family: z
900
+ .string()
901
+ .optional()
902
+ .describe(
903
+ "Overlay font (default ShortFontSpaceGrotesk). One of: ShortFontSpaceGrotesk, ShortFontMontserrat, " +
904
+ "ShortFontTheBold, ShortFontImpact, ShortFontLato, ShortFontProximaNova, ShortFontAnton, " +
905
+ "ShortFontBebasNeue, ShortFontOswald, ShortFontArchivoBlack, ShortFontPoppins, ShortFontInter, " +
906
+ "ShortFontTikTokSans, ShortFontBangers.",
907
+ ),
787
908
  },
788
909
  annotations: { title: "Create short", ...WRITE, idempotentHint: true },
789
910
  },
790
- tool(async (args: Record<string, unknown>, client) => {
791
- const res = await client.post<{ data: { slug: string } }>(
792
- "/shorts",
793
- args,
794
- idemKey("create-short", String(args.product_prompt ?? "")),
795
- );
796
- return ok({ slug: res.data.slug, kind: "short", next: "generate_short" });
797
- }),
911
+ tool(
912
+ async (
913
+ args: {
914
+ product_prompt: string;
915
+ language?: string;
916
+ headline?: string;
917
+ subheadline?: string;
918
+ theme?: string;
919
+ music_vibe?: string;
920
+ music_instruments?: string[];
921
+ text_position?: string;
922
+ text_animation?: string;
923
+ font_family?: string;
924
+ },
925
+ client,
926
+ ) => {
927
+ // Map the agent-friendly field names onto the API's short_* params.
928
+ const body: Record<string, unknown> = {
929
+ product_prompt: args.product_prompt,
930
+ };
931
+ if (args.language !== undefined) body.language = args.language;
932
+ if (args.headline !== undefined) body.headline = args.headline;
933
+ if (args.subheadline !== undefined) body.subheadline = args.subheadline;
934
+ if (args.theme !== undefined) body.theme = args.theme;
935
+ if (args.music_vibe !== undefined) body.music_vibe = args.music_vibe;
936
+ if (args.music_instruments !== undefined)
937
+ body.music_instruments = args.music_instruments;
938
+ if (args.text_position !== undefined)
939
+ body.short_text_position = args.text_position;
940
+ if (args.text_animation !== undefined)
941
+ body.short_text_animation = args.text_animation;
942
+ if (args.font_family !== undefined)
943
+ body.short_font_family = args.font_family;
944
+ const res = await client.post<{ data: { slug: string } }>(
945
+ "/shorts",
946
+ body,
947
+ idemKey(
948
+ "create-short",
949
+ args.product_prompt,
950
+ args.language ?? "",
951
+ args.headline ?? "",
952
+ args.subheadline ?? "",
953
+ ),
954
+ );
955
+ return ok({
956
+ slug: res.data.slug,
957
+ kind: "short",
958
+ next: "set_short_product / set_short_poster (optional branding), then generate_short",
959
+ });
960
+ },
961
+ ),
798
962
  );
799
963
 
800
964
  registerTool(
@@ -828,7 +992,11 @@ registerTool(
828
992
  "Creates an editor project from a product prompt and starts autopilot (server-orchestrated " +
829
993
  "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
830
994
  "Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " +
831
- "make_video for the one-shot path.",
995
+ "make_video for the one-shot path. " +
996
+ "BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " +
997
+ "create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " +
998
+ "start_autopilot — autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " +
999
+ "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.)",
832
1000
  inputSchema: {
833
1001
  product_prompt: z
834
1002
  .string()
@@ -841,7 +1009,9 @@ registerTool(
841
1009
  theme: z
842
1010
  .string()
843
1011
  .optional()
844
- .describe('Visual theme (default "realistic")'),
1012
+ .describe(
1013
+ 'Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
1014
+ ),
845
1015
  voice_id: z
846
1016
  .string()
847
1017
  .optional()
@@ -986,7 +1156,9 @@ registerTool(
986
1156
  theme: z
987
1157
  .string()
988
1158
  .optional()
989
- .describe('Visual theme (default "realistic")'),
1159
+ .describe(
1160
+ 'Visual theme (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.',
1161
+ ),
990
1162
  voice_id: z
991
1163
  .string()
992
1164
  .regex(/^[A-Za-z0-9_-]+$/)
@@ -2158,6 +2330,89 @@ registerTool(
2158
2330
  ),
2159
2331
  );
2160
2332
 
2333
+ // ── Shorts: branding images (product + end-card poster) ──────────────────────
2334
+ //
2335
+ // The editor branding tools above hit /editor/...; these hit /shorts/... A short
2336
+ // has no logo overlay (editor-only), but it CAN feature a product image woven
2337
+ // into the footage and a closing end-card poster. Both are 0 credits.
2338
+
2339
+ registerTool(
2340
+ "set_short_product",
2341
+ {
2342
+ title: "Attach a product image to a short (0 credits)",
2343
+ description:
2344
+ "Uploads a local product image and attaches it to a SHORT so the product is woven into the footage. " +
2345
+ "Accepted: " +
2346
+ IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
2347
+ " (≤20 MB), local path confined to HUBFLUENCER_INPUT_DIR (or cwd). 0 credits. Optional description " +
2348
+ "(≤500 chars) guides how it's featured. Attach BEFORE generate_short so the product is part of the scenes. " +
2349
+ "(This is the shorts equivalent of set_product for editor projects.)",
2350
+ inputSchema: {
2351
+ slug: z.string().describe("Short slug (from create_short)"),
2352
+ file_path: z
2353
+ .string()
2354
+ .describe("Local product image path (.jpg/.jpeg/.png)"),
2355
+ description: z
2356
+ .string()
2357
+ .max(500)
2358
+ .optional()
2359
+ .describe("Product description (≤500 chars)"),
2360
+ },
2361
+ annotations: {
2362
+ title: "Set short product",
2363
+ ...WRITE,
2364
+ idempotentHint: false,
2365
+ },
2366
+ },
2367
+ tool(
2368
+ async (
2369
+ args: { slug: string; file_path: string; description?: string },
2370
+ client,
2371
+ ) => {
2372
+ const { s3_key } = await uploadImageFile(
2373
+ client,
2374
+ `/shorts/${args.slug}/product/presign`,
2375
+ args.file_path,
2376
+ );
2377
+ const res = await client.post<{ data: unknown }>(
2378
+ `/shorts/${args.slug}/product/confirm`,
2379
+ { s3_key, product_description: args.description },
2380
+ );
2381
+ return ok(asRecord(res).data ?? res);
2382
+ },
2383
+ ),
2384
+ );
2385
+
2386
+ registerTool(
2387
+ "set_short_poster",
2388
+ {
2389
+ title: "Set a short's end-card poster image (0 credits)",
2390
+ description:
2391
+ "Uploads a local image as the SHORT's end-card poster — a closing still shown at the end (extends the " +
2392
+ "render from 12s to 14s). Accepted: " +
2393
+ IMAGE_EXTS.map((e) => `.${e}`).join(", ") +
2394
+ " (≤20 MB), confined to HUBFLUENCER_INPUT_DIR/cwd. 0 credits. There is one poster per short; calling " +
2395
+ "again replaces it. (Shorts have no logo overlay — for a brand logo use an editor project + set_logo.)",
2396
+ inputSchema: {
2397
+ slug: z.string().describe("Short slug (from create_short)"),
2398
+ file_path: z.string().describe("Local poster image (.jpg/.jpeg/.png)"),
2399
+ },
2400
+ annotations: { title: "Set short poster", ...WRITE, idempotentHint: false },
2401
+ },
2402
+ tool(async (args: { slug: string; file_path: string }, client) => {
2403
+ const { s3_key } = await uploadShortPoster(
2404
+ client,
2405
+ args.slug,
2406
+ args.file_path,
2407
+ );
2408
+ const res = await client.post<{ data: unknown }>(
2409
+ `/shorts/${args.slug}/poster/confirm`,
2410
+ { s3_key },
2411
+ );
2412
+ return ok(asRecord(res).data ?? res);
2413
+ }),
2414
+ );
2415
+
2161
2416
  // ── Status / waiting ──────────────────────────────────────────────────────────
2162
2417
 
2163
2418
  registerTool(
@@ -2298,6 +2553,13 @@ registerTool(
2298
2553
  // ── Boot ──────────────────────────────────────────────────────────────────────
2299
2554
 
2300
2555
  async function main() {
2556
+ // `hubfluencer-mcp login [name]` runs the device-link login instead of the
2557
+ // server. Single bin keeps the install idiomatic: `npx -y @hubfluencer/mcp`
2558
+ // starts the server; `npx -y @hubfluencer/mcp login` connects an account.
2559
+ if (process.argv[2] === "login") {
2560
+ await runLogin(process.argv[3]);
2561
+ return;
2562
+ }
2301
2563
  const transport = new StdioServerTransport();
2302
2564
  await server.connect(transport);
2303
2565
  // stderr is safe; stdout is reserved for the MCP protocol.
package/src/login.ts CHANGED
@@ -1,6 +1,9 @@
1
- #!/usr/bin/env node
2
1
  /**
3
- * `hubfluencer-login` device-link CLI.
2
+ * Device-link login for the Hubfluencer MCP.
3
+ *
4
+ * Exposed as the `login` subcommand of the single `hubfluencer-mcp` bin:
5
+ *
6
+ * npx -y @hubfluencer/mcp login ["Client Name"]
4
7
  *
5
8
  * Connects this agent to a Hubfluencer account without copy-pasting a token:
6
9
  * starts a link request, prints a URL + code for the user to approve in the
@@ -14,17 +17,21 @@ import { dirname } from "node:path";
14
17
  import { assertSafeBaseUrl } from "./client.js";
15
18
  import { CREDENTIALS_PATH } from "./credentials.js";
16
19
 
17
- const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com")
18
- .replace(/\/+$/, "")
19
- .replace(/\/api$/, "");
20
- // Fail before printing a code / polling if the base would leak the token.
21
- assertSafeBaseUrl(BASE);
22
20
  const MAX_POLLS = 120;
23
21
 
24
22
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
25
23
 
26
- async function main() {
27
- const clientName = process.argv[2] || "Claude Code";
24
+ /**
25
+ * Runs the device-link login flow. Resolves once the token is stored; calls
26
+ * `process.exit(1)` on a terminal failure (it's a CLI entry path). Invoked from
27
+ * index.ts when the bin is run as `hubfluencer-mcp login [name]`.
28
+ */
29
+ export async function runLogin(clientName = "Claude Code"): Promise<void> {
30
+ const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com")
31
+ .replace(/\/+$/, "")
32
+ .replace(/\/api$/, "");
33
+ // Fail before printing a code / polling if the base would leak the token.
34
+ assertSafeBaseUrl(BASE);
28
35
 
29
36
  const startRes = await fetch(`${BASE}/api/agent-link/start`, {
30
37
  method: "POST",
@@ -77,7 +84,7 @@ async function main() {
77
84
  };
78
85
 
79
86
  if (pollRes.ok && body.status === "approved" && body.token) {
80
- await storeToken(body.token);
87
+ await storeToken(body.token, BASE);
81
88
  console.error(
82
89
  `\n✓ Connected. Access token saved to ${CREDENTIALS_PATH}.`,
83
90
  );
@@ -101,12 +108,12 @@ async function main() {
101
108
  process.exit(1);
102
109
  }
103
110
 
104
- async function storeToken(token: string) {
111
+ async function storeToken(token: string, base: string) {
105
112
  const dir = dirname(CREDENTIALS_PATH);
106
113
  await mkdir(dir, { recursive: true, mode: 0o700 });
107
114
  await writeFile(
108
115
  CREDENTIALS_PATH,
109
- JSON.stringify({ token, base_url: BASE }, null, 2),
116
+ JSON.stringify({ token, base_url: base }, null, 2),
110
117
  {
111
118
  mode: 0o600,
112
119
  },
@@ -122,8 +129,3 @@ async function storeToken(token: string) {
122
129
  // chmod is hardening — never fail the login over it
123
130
  }
124
131
  }
125
-
126
- main().catch((e) => {
127
- console.error("Fatal:", e instanceof Error ? e.message : String(e));
128
- process.exit(1);
129
- });
package/src/uploads.ts CHANGED
@@ -365,5 +365,30 @@ export async function uploadImageFile(
365
365
  return { s3_key };
366
366
  }
367
367
 
368
+ /**
369
+ * Uploads a local image as a short's end-card POSTER. The short poster presign
370
+ * is older than the editor asset presign and uses a different envelope: it takes
371
+ * `{ content_type }` and returns a bare `{ upload_url, s3_key }` (no `data`
372
+ * wrapper, `upload_url` not `presigned_url`, and a single fixed key per short).
373
+ * Kept separate from `uploadImageFile` (which speaks the editor `{data:{...}}`
374
+ * shape) so neither has to branch on the surface. Returns the `s3_key` to thread
375
+ * into `/shorts/:slug/poster/confirm`.
376
+ */
377
+ export async function uploadShortPoster(
378
+ client: HubfluencerClient,
379
+ slug: string,
380
+ filePath: string,
381
+ ): Promise<{ s3_key: string }> {
382
+ const file = await resolveReadPath(filePath, IMAGE_EXT_MIME, MAX_IMAGE_BYTES);
383
+ const presign = await client.post<{ upload_url: string; s3_key: string }>(
384
+ `/shorts/${slug}/poster/presign`,
385
+ { content_type: file.mime },
386
+ );
387
+ const { upload_url, s3_key } = presign;
388
+ const buf = await readFile(file.path);
389
+ await putToPresignedUrl(upload_url, buf, file.mime);
390
+ return { s3_key };
391
+ }
392
+
368
393
  export const LOGO_MAX_BYTES = MAX_LOGO_BYTES;
369
394
  export const IMAGE_MAX_BYTES = MAX_IMAGE_BYTES;