fluncle 0.85.0 → 0.86.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.
Files changed (2) hide show
  1. package/bin/fluncle.mjs +192 -242
  2. package/package.json +1 -1
package/bin/fluncle.mjs CHANGED
@@ -538,7 +538,7 @@ function parseVersion(version) {
538
538
  var currentVersion;
539
539
  var init_version = __esm(() => {
540
540
  init_output();
541
- currentVersion = "0.85.0".trim() ? "0.85.0".trim() : "0.1.0";
541
+ currentVersion = "0.86.0".trim() ? "0.86.0".trim() : "0.1.0";
542
542
  });
543
543
 
544
544
  // src/update-notifier.ts
@@ -1329,186 +1329,6 @@ var init_mixtape_mixcloud = __esm(() => {
1329
1329
  PICTURE_MAX_BYTES = 10 * 1024 * 1024;
1330
1330
  });
1331
1331
 
1332
- // src/commands/mixtape-set-video.ts
1333
- var exports_mixtape_set_video = {};
1334
- __export(exports_mixtape_set_video, {
1335
- uploadRenditionMultipart: () => uploadRenditionMultipart,
1336
- stageSetVideo: () => stageSetVideo,
1337
- renditionFfmpegArgs: () => renditionFfmpegArgs,
1338
- planMultipart: () => planMultipart,
1339
- buildCompleteXml: () => buildCompleteXml,
1340
- SET_VIDEO_RENDITION: () => SET_VIDEO_RENDITION,
1341
- MIN_PART_SIZE: () => MIN_PART_SIZE,
1342
- MAX_PARTS: () => MAX_PARTS,
1343
- DEFAULT_PART_SIZE: () => DEFAULT_PART_SIZE
1344
- });
1345
- import { randomUUID } from "node:crypto";
1346
- import { existsSync, rmSync as rmSync2, statSync as statSync2 } from "node:fs";
1347
- import { tmpdir } from "node:os";
1348
- import { join as join4 } from "node:path";
1349
- function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
1350
- if (!Number.isInteger(contentLength) || contentLength <= 0) {
1351
- throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
1352
- }
1353
- let effective = Math.max(partSize, MIN_PART_SIZE);
1354
- if (Math.ceil(contentLength / effective) > MAX_PARTS) {
1355
- effective = Math.ceil(contentLength / MAX_PARTS);
1356
- }
1357
- const parts = [];
1358
- let start = 0;
1359
- let partNumber = 1;
1360
- while (start < contentLength) {
1361
- const end = Math.min(start + effective, contentLength);
1362
- parts.push({ end, partNumber, size: end - start, start });
1363
- start = end;
1364
- partNumber += 1;
1365
- }
1366
- return { partCount: parts.length, partSize: effective, parts };
1367
- }
1368
- function escapeXml(value) {
1369
- return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
1370
- }
1371
- function buildCompleteXml(parts) {
1372
- const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
1373
- const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
1374
- return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
1375
- }
1376
- function renditionFfmpegArgs(inputPath, outputPath) {
1377
- return [
1378
- "-y",
1379
- "-i",
1380
- inputPath,
1381
- "-vf",
1382
- `scale=-2:${SET_VIDEO_RENDITION.height}`,
1383
- "-c:v",
1384
- "libx264",
1385
- "-preset",
1386
- "medium",
1387
- "-crf",
1388
- String(SET_VIDEO_RENDITION.crf),
1389
- "-force_key_frames",
1390
- `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
1391
- "-pix_fmt",
1392
- "yuv420p",
1393
- "-c:a",
1394
- "aac",
1395
- "-b:a",
1396
- SET_VIDEO_RENDITION.audioBitrate,
1397
- "-movflags",
1398
- "+faststart",
1399
- outputPath
1400
- ];
1401
- }
1402
- async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
1403
- if (!existsSync(masterPath)) {
1404
- throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
1405
- }
1406
- await assertFfmpeg();
1407
- const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
1408
- onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
1409
- await deriveRendition(masterPath, renditionPath);
1410
- try {
1411
- const size = statSync2(renditionPath).size;
1412
- const plan = planMultipart(size);
1413
- onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
1414
- const presign = await adminApiPost(presignPath, {
1415
- partCount: plan.partCount
1416
- });
1417
- const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
1418
- const completed = [];
1419
- try {
1420
- for (const part of plan.parts) {
1421
- const url = urlByPart.get(part.partNumber);
1422
- if (!url) {
1423
- throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
1424
- }
1425
- onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
1426
- const etag = await putPart(url, renditionPath, part);
1427
- completed.push({ etag, partNumber: part.partNumber });
1428
- }
1429
- await completeUpload(presign.completeUrl, completed);
1430
- } catch (error) {
1431
- await abortUpload(presign.abortUrl).catch(() => {});
1432
- throw error;
1433
- }
1434
- return { key: presign.key, url: `${FOUND_BASE}/${presign.key}` };
1435
- } finally {
1436
- rmSync2(renditionPath, { force: true });
1437
- }
1438
- }
1439
- async function stageSetVideo(mixtapeId, masterPath, onProgress = () => {}) {
1440
- const result = await uploadRenditionMultipart(masterPath, `/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}/set-video/presign`, onProgress);
1441
- await adminApiPatch(`/api/admin/mixtapes/${encodeURIComponent(mixtapeId)}`, {
1442
- setVideoAt: new Date().toISOString()
1443
- });
1444
- onProgress("Set video: flipped setVideoAt — the /log player + video SEO are live.");
1445
- return result;
1446
- }
1447
- async function putPart(url, path2, part) {
1448
- const response = await fetch(url, {
1449
- body: Bun.file(path2).slice(part.start, part.end),
1450
- method: "PUT"
1451
- });
1452
- if (!response.ok) {
1453
- const detail = (await response.text().catch(() => "")).slice(0, 300);
1454
- throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
1455
- }
1456
- const etag = response.headers.get("etag");
1457
- if (!etag) {
1458
- throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
1459
- }
1460
- return etag;
1461
- }
1462
- async function completeUpload(url, parts) {
1463
- const response = await fetch(url, {
1464
- body: buildCompleteXml(parts),
1465
- headers: { "content-type": "application/xml" },
1466
- method: "POST"
1467
- });
1468
- const text = await response.text().catch(() => "");
1469
- if (!response.ok || text.includes("<Error>")) {
1470
- throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
1471
- }
1472
- }
1473
- async function abortUpload(url) {
1474
- await fetch(url, { method: "DELETE" });
1475
- }
1476
- async function assertFfmpeg() {
1477
- try {
1478
- const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
1479
- await proc.exited;
1480
- if (proc.exitCode !== 0) {
1481
- throw new Error("ffmpeg -version exited non-zero");
1482
- }
1483
- } catch {
1484
- throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
1485
- }
1486
- }
1487
- async function deriveRendition(inputPath, outputPath) {
1488
- const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
1489
- stderr: "pipe",
1490
- stdout: "ignore"
1491
- });
1492
- await proc.exited;
1493
- if (proc.exitCode !== 0) {
1494
- const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
1495
- throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
1496
- }
1497
- }
1498
- var FOUND_BASE = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
1499
- var init_mixtape_set_video = __esm(() => {
1500
- init_api();
1501
- init_output();
1502
- SET_VIDEO_RENDITION = {
1503
- audioBitrate: "192k",
1504
- crf: 20,
1505
- gopSeconds: 2,
1506
- height: 1080
1507
- };
1508
- DEFAULT_PART_SIZE = 100 * 1024 * 1024;
1509
- MIN_PART_SIZE = 5 * 1024 * 1024;
1510
- });
1511
-
1512
1332
  // src/commands/mixtapes.ts
1513
1333
  var exports_mixtapes = {};
1514
1334
  __export(exports_mixtapes, {
@@ -1523,7 +1343,7 @@ __export(exports_mixtapes, {
1523
1343
  mixtapeDeleteCommand: () => mixtapeDeleteCommand,
1524
1344
  mixtapeCreateCommand: () => mixtapeCreateCommand
1525
1345
  });
1526
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
1346
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
1527
1347
  async function mixtapesCommand() {
1528
1348
  const response = await publicApiGet("/api/mixtapes");
1529
1349
  return response.mixtapes;
@@ -1552,21 +1372,24 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1552
1372
  if (!mixtape.id) {
1553
1373
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
1554
1374
  }
1555
- const both = !options.youtube && !options.mixcloud && !options.setVideo;
1375
+ if (mixtape.status === "draft") {
1376
+ throw new CliError2("mixtape_not_promoted", `${mixtape.id} is still a draft — promote its recording first:
1377
+ ` + " fluncle admin recordings promote <recordingId>");
1378
+ }
1379
+ const both = !options.youtube && !options.mixcloud;
1556
1380
  const doYoutube = both || Boolean(options.youtube);
1557
1381
  const doMixcloud = both || Boolean(options.mixcloud);
1558
- const doSetVideo = Boolean(options.setVideo);
1559
1382
  if (doYoutube && !options.video) {
1560
1383
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
1561
1384
  }
1562
1385
  if (doMixcloud && !options.audio) {
1563
1386
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
1564
1387
  }
1565
- if (doSetVideo && !options.video) {
1566
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1567
- }
1568
1388
  const mixtapeId = mixtape.id;
1569
- let logId = mixtape.logId;
1389
+ const logId = mixtape.logId;
1390
+ if (!logId) {
1391
+ throw new CliError2("missing_log_id", "The mixtape has no Log ID — was it promoted successfully?");
1392
+ }
1570
1393
  if (!mixtape.durationMs) {
1571
1394
  const source = options.audio ?? options.video;
1572
1395
  const durationMs = source ? await probeDurationMs(source) : undefined;
@@ -1575,18 +1398,10 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1575
1398
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
1576
1399
  }
1577
1400
  }
1578
- if (mixtape.status === "draft") {
1579
- onProgress("Minting the coordinate…");
1580
- const published = await mixtapePublishCommand(mixtapeId);
1581
- logId = published.mixtape.logId;
1582
- onProgress(`Minted ${logId}.`);
1583
- } else if (mixtape.status === "published") {
1401
+ if (mixtape.status === "published") {
1584
1402
  onProgress(`Already published (${logId}); re-distributing.`);
1585
1403
  } else {
1586
- onProgress(`Resuming distribution for ${logId}.`);
1587
- }
1588
- if (!logId) {
1589
- throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
1404
+ onProgress(`Distributing ${logId} (promoted — coordinate already minted).`);
1590
1405
  }
1591
1406
  const results = [];
1592
1407
  if (doYoutube) {
@@ -1609,16 +1424,6 @@ async function mixtapeDistributeCommand(idOrLogId, options, onProgress = () => {
1609
1424
  results.push({ platform: "mixcloud", url: result.url });
1610
1425
  onProgress(`Mixcloud: ${result.url}`);
1611
1426
  }
1612
- if (doSetVideo) {
1613
- if (!options.video) {
1614
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
1615
- }
1616
- onProgress("Set video: staging the 1080p rendition…");
1617
- const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
1618
- const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
1619
- results.push({ platform: "set-video", url: result.url });
1620
- onProgress(`Set video: ${result.url}`);
1621
- }
1622
1427
  return { logId, mixtapeId, results };
1623
1428
  }
1624
1429
  async function mixtapeResyncCommand(idOrLogId, options, onProgress = () => {}) {
@@ -1691,7 +1496,7 @@ function buildBody(options) {
1691
1496
  return body;
1692
1497
  }
1693
1498
  function parseCueFile(filePath) {
1694
- if (!existsSync2(filePath)) {
1499
+ if (!existsSync(filePath)) {
1695
1500
  throw new CliError2("file_not_found", `Cue file not found: ${filePath}`);
1696
1501
  }
1697
1502
  const text = readFileSync2(filePath, "utf-8");
@@ -2446,7 +2251,7 @@ function parseVersion2(version) {
2446
2251
  var currentVersion2, latestReleaseUrl = "https://api.github.com/repos/mauricekleine/fluncle/releases/latest";
2447
2252
  var init_version2 = __esm(() => {
2448
2253
  init_output();
2449
- currentVersion2 = "0.85.0".trim() ? "0.85.0".trim() : "0.1.0";
2254
+ currentVersion2 = "0.86.0".trim() ? "0.86.0".trim() : "0.1.0";
2450
2255
  });
2451
2256
 
2452
2257
  // ../../packages/registry/src/index.ts
@@ -3200,7 +3005,7 @@ async function trackVideoCommand(idOrLogId, files, onProgress) {
3200
3005
  const detail = (await response.text().catch(() => "")).slice(0, 300);
3201
3006
  throw new CliError2("r2_put_failed", `R2 rejected ${spec.field} with ${response.status} ${response.statusText}${detail ? `: ${detail}` : ""}`);
3202
3007
  }
3203
- urls[spec.field] = `${FOUND_BASE2}/${upload.key}`;
3008
+ urls[spec.field] = `${FOUND_BASE}/${upload.key}`;
3204
3009
  }
3205
3010
  const manifest = files.render ? await readManifestFields(files.render) : {};
3206
3011
  const videoModel = files.model?.trim().slice(0, 120) || manifest.model || DEFAULT_VIDEO_MODEL;
@@ -3311,7 +3116,7 @@ async function trackNoteCommand(idOrLogId, options) {
3311
3116
  const body = { note: options.note };
3312
3117
  return adminApiPost(`/api/admin/tracks/${encodeURIComponent(idOrLogId)}/note`, body);
3313
3118
  }
3314
- var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE2 = "https://found.fluncle.com", VIDEO_FIELDS;
3119
+ var DEFAULT_VIDEO_MODEL = "anthropic/claude-opus-4-8", DEFAULT_VIDEO_REASONING = "high", FOUND_BASE = "https://found.fluncle.com", VIDEO_FIELDS;
3315
3120
  var init_track = __esm(() => {
3316
3121
  init_api();
3317
3122
  init_output();
@@ -3705,7 +3510,7 @@ __export(exports_mixtape_youtube2, {
3705
3510
  distributeYoutube: () => distributeYoutube2,
3706
3511
  authYoutubeCommand: () => authYoutubeCommand2
3707
3512
  });
3708
- import { statSync as statSync3 } from "node:fs";
3513
+ import { statSync as statSync2 } from "node:fs";
3709
3514
  async function distributeYoutube2(mixtapeId, videoPath, onProgress) {
3710
3515
  const contentLength = fileSize2(videoPath);
3711
3516
  const contentType = "video/mp4";
@@ -3841,7 +3646,7 @@ async function resolveMixtapeId2(idOrLogId) {
3841
3646
  }
3842
3647
  function fileSize2(path2) {
3843
3648
  try {
3844
- return statSync3(path2).size;
3649
+ return statSync2(path2).size;
3845
3650
  } catch {
3846
3651
  throw new CliError2("video_not_found", `Cannot read video file at ${path2}`);
3847
3652
  }
@@ -3861,6 +3666,166 @@ var init_mixtape_youtube2 = __esm(() => {
3861
3666
  init_output();
3862
3667
  });
3863
3668
 
3669
+ // src/commands/mixtape-set-video.ts
3670
+ import { randomUUID } from "node:crypto";
3671
+ import { existsSync as existsSync2, rmSync as rmSync2, statSync as statSync3 } from "node:fs";
3672
+ import { tmpdir } from "node:os";
3673
+ import { join as join4 } from "node:path";
3674
+ function planMultipart(contentLength, partSize = DEFAULT_PART_SIZE) {
3675
+ if (!Number.isInteger(contentLength) || contentLength <= 0) {
3676
+ throw new CliError2("invalid_size", `The rendition size must be a positive integer (got ${contentLength})`);
3677
+ }
3678
+ let effective = Math.max(partSize, MIN_PART_SIZE);
3679
+ if (Math.ceil(contentLength / effective) > MAX_PARTS) {
3680
+ effective = Math.ceil(contentLength / MAX_PARTS);
3681
+ }
3682
+ const parts = [];
3683
+ let start = 0;
3684
+ let partNumber = 1;
3685
+ while (start < contentLength) {
3686
+ const end = Math.min(start + effective, contentLength);
3687
+ parts.push({ end, partNumber, size: end - start, start });
3688
+ start = end;
3689
+ partNumber += 1;
3690
+ }
3691
+ return { partCount: parts.length, partSize: effective, parts };
3692
+ }
3693
+ function escapeXml(value) {
3694
+ return value.replace(/[<>&'"]/g, (char) => ({ '"': "&quot;", "&": "&amp;", "'": "&apos;", "<": "&lt;", ">": "&gt;" })[char] ?? char);
3695
+ }
3696
+ function buildCompleteXml(parts) {
3697
+ const ordered = [...parts].sort((a, b) => a.partNumber - b.partNumber);
3698
+ const body = ordered.map((part) => `<Part><PartNumber>${part.partNumber}</PartNumber><ETag>${escapeXml(part.etag)}</ETag></Part>`).join("");
3699
+ return `<CompleteMultipartUpload>${body}</CompleteMultipartUpload>`;
3700
+ }
3701
+ function renditionFfmpegArgs(inputPath, outputPath) {
3702
+ return [
3703
+ "-y",
3704
+ "-i",
3705
+ inputPath,
3706
+ "-vf",
3707
+ `scale=-2:${SET_VIDEO_RENDITION.height}`,
3708
+ "-c:v",
3709
+ "libx264",
3710
+ "-preset",
3711
+ "medium",
3712
+ "-crf",
3713
+ String(SET_VIDEO_RENDITION.crf),
3714
+ "-force_key_frames",
3715
+ `expr:gte(t,n_forced*${SET_VIDEO_RENDITION.gopSeconds})`,
3716
+ "-pix_fmt",
3717
+ "yuv420p",
3718
+ "-c:a",
3719
+ "aac",
3720
+ "-b:a",
3721
+ SET_VIDEO_RENDITION.audioBitrate,
3722
+ "-movflags",
3723
+ "+faststart",
3724
+ outputPath
3725
+ ];
3726
+ }
3727
+ async function uploadRenditionMultipart(masterPath, presignPath, onProgress = () => {}) {
3728
+ if (!existsSync2(masterPath)) {
3729
+ throw new CliError2("file_not_found", `Set-video master not found: ${masterPath}`);
3730
+ }
3731
+ await assertFfmpeg();
3732
+ const renditionPath = join4(tmpdir(), `fluncle-set-${randomUUID()}.mp4`);
3733
+ onProgress("Set video: deriving the 1080p faststart rendition (ffmpeg)…");
3734
+ await deriveRendition(masterPath, renditionPath);
3735
+ try {
3736
+ const size = statSync3(renditionPath).size;
3737
+ const plan = planMultipart(size);
3738
+ onProgress(`Set video: uploading ${(size / 1e9).toFixed(2)} GB in ${plan.partCount} part(s)…`);
3739
+ const presign = await adminApiPost(presignPath, {
3740
+ partCount: plan.partCount
3741
+ });
3742
+ const urlByPart = new Map(presign.parts.map((part) => [part.partNumber, part.url]));
3743
+ const completed = [];
3744
+ try {
3745
+ for (const part of plan.parts) {
3746
+ const url = urlByPart.get(part.partNumber);
3747
+ if (!url) {
3748
+ throw new CliError2("presign_missing", `Worker did not sign part ${part.partNumber} of ${plan.partCount}`);
3749
+ }
3750
+ onProgress(`Set video: part ${part.partNumber}/${plan.partCount}`);
3751
+ const etag = await putPart(url, renditionPath, part);
3752
+ completed.push({ etag, partNumber: part.partNumber });
3753
+ }
3754
+ await completeUpload(presign.completeUrl, completed);
3755
+ } catch (error) {
3756
+ await abortUpload(presign.abortUrl).catch(() => {});
3757
+ throw error;
3758
+ }
3759
+ return { key: presign.key, url: `${FOUND_BASE2}/${presign.key}` };
3760
+ } finally {
3761
+ rmSync2(renditionPath, { force: true });
3762
+ }
3763
+ }
3764
+ async function putPart(url, path2, part) {
3765
+ const response = await fetch(url, {
3766
+ body: Bun.file(path2).slice(part.start, part.end),
3767
+ method: "PUT"
3768
+ });
3769
+ if (!response.ok) {
3770
+ const detail = (await response.text().catch(() => "")).slice(0, 300);
3771
+ throw new CliError2("r2_part_failed", `R2 rejected part ${part.partNumber} (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
3772
+ }
3773
+ const etag = response.headers.get("etag");
3774
+ if (!etag) {
3775
+ throw new CliError2("r2_no_etag", `R2 returned no ETag for part ${part.partNumber}`);
3776
+ }
3777
+ return etag;
3778
+ }
3779
+ async function completeUpload(url, parts) {
3780
+ const response = await fetch(url, {
3781
+ body: buildCompleteXml(parts),
3782
+ headers: { "content-type": "application/xml" },
3783
+ method: "POST"
3784
+ });
3785
+ const text = await response.text().catch(() => "");
3786
+ if (!response.ok || text.includes("<Error>")) {
3787
+ throw new CliError2("r2_complete_failed", `R2 CompleteMultipartUpload failed (${response.status} ${response.statusText})${text ? `: ${text.slice(0, 300)}` : ""}`);
3788
+ }
3789
+ }
3790
+ async function abortUpload(url) {
3791
+ await fetch(url, { method: "DELETE" });
3792
+ }
3793
+ async function assertFfmpeg() {
3794
+ try {
3795
+ const proc = Bun.spawn(["ffmpeg", "-version"], { stderr: "ignore", stdout: "ignore" });
3796
+ await proc.exited;
3797
+ if (proc.exitCode !== 0) {
3798
+ throw new Error("ffmpeg -version exited non-zero");
3799
+ }
3800
+ } catch {
3801
+ throw new CliError2("ffmpeg_missing", "--set-video needs ffmpeg to derive the 1080p rendition. Install it (brew install ffmpeg).");
3802
+ }
3803
+ }
3804
+ async function deriveRendition(inputPath, outputPath) {
3805
+ const proc = Bun.spawn(["ffmpeg", ...renditionFfmpegArgs(inputPath, outputPath)], {
3806
+ stderr: "pipe",
3807
+ stdout: "ignore"
3808
+ });
3809
+ await proc.exited;
3810
+ if (proc.exitCode !== 0) {
3811
+ const detail = (await new Response(proc.stderr).text().catch(() => "")).slice(-400);
3812
+ throw new CliError2("ffmpeg_failed", `ffmpeg failed to derive the set-video rendition${detail ? `: ${detail}` : ""}`);
3813
+ }
3814
+ }
3815
+ var FOUND_BASE2 = "https://found.fluncle.com", SET_VIDEO_RENDITION, DEFAULT_PART_SIZE, MIN_PART_SIZE, MAX_PARTS = 1e4;
3816
+ var init_mixtape_set_video = __esm(() => {
3817
+ init_api();
3818
+ init_output();
3819
+ SET_VIDEO_RENDITION = {
3820
+ audioBitrate: "192k",
3821
+ crf: 20,
3822
+ gopSeconds: 2,
3823
+ height: 1080
3824
+ };
3825
+ DEFAULT_PART_SIZE = 100 * 1024 * 1024;
3826
+ MIN_PART_SIZE = 5 * 1024 * 1024;
3827
+ });
3828
+
3864
3829
  // src/commands/recordings.ts
3865
3830
  var exports_recordings = {};
3866
3831
  __export(exports_recordings, {
@@ -4092,21 +4057,24 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
4092
4057
  if (!mixtape.id) {
4093
4058
  throw new CliError2("mixtape_not_found", `No mixtape with id or log id ${idOrLogId}`);
4094
4059
  }
4095
- const both = !options.youtube && !options.mixcloud && !options.setVideo;
4060
+ if (mixtape.status === "draft") {
4061
+ throw new CliError2("mixtape_not_promoted", `${mixtape.id} is still a draft — promote its recording first:
4062
+ ` + " fluncle admin recordings promote <recordingId>");
4063
+ }
4064
+ const both = !options.youtube && !options.mixcloud;
4096
4065
  const doYoutube = both || Boolean(options.youtube);
4097
4066
  const doMixcloud = both || Boolean(options.mixcloud);
4098
- const doSetVideo = Boolean(options.setVideo);
4099
4067
  if (doYoutube && !options.video) {
4100
4068
  throw new CliError2("missing_video", "YouTube distribution needs --video <mp4>");
4101
4069
  }
4102
4070
  if (doMixcloud && !options.audio) {
4103
4071
  throw new CliError2("missing_audio", "Mixcloud distribution needs --audio <file>");
4104
4072
  }
4105
- if (doSetVideo && !options.video) {
4106
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
4107
- }
4108
4073
  const mixtapeId = mixtape.id;
4109
- let logId = mixtape.logId;
4074
+ const logId = mixtape.logId;
4075
+ if (!logId) {
4076
+ throw new CliError2("missing_log_id", "The mixtape has no Log ID — was it promoted successfully?");
4077
+ }
4110
4078
  if (!mixtape.durationMs) {
4111
4079
  const source = options.audio ?? options.video;
4112
4080
  const durationMs = source ? await probeDurationMs2(source) : undefined;
@@ -4115,18 +4083,10 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
4115
4083
  onProgress(`Duration: ${Math.round(durationMs / 60000)} min (from the upload).`);
4116
4084
  }
4117
4085
  }
4118
- if (mixtape.status === "draft") {
4119
- onProgress("Minting the coordinate…");
4120
- const published = await mixtapePublishCommand2(mixtapeId);
4121
- logId = published.mixtape.logId;
4122
- onProgress(`Minted ${logId}.`);
4123
- } else if (mixtape.status === "published") {
4086
+ if (mixtape.status === "published") {
4124
4087
  onProgress(`Already published (${logId}); re-distributing.`);
4125
4088
  } else {
4126
- onProgress(`Resuming distribution for ${logId}.`);
4127
- }
4128
- if (!logId) {
4129
- throw new CliError2("mint_failed", "The mixtape has no Log ID after minting");
4089
+ onProgress(`Distributing ${logId} (promoted — coordinate already minted).`);
4130
4090
  }
4131
4091
  const results = [];
4132
4092
  if (doYoutube) {
@@ -4149,16 +4109,6 @@ async function mixtapeDistributeCommand2(idOrLogId, options, onProgress = () =>
4149
4109
  results.push({ platform: "mixcloud", url: result.url });
4150
4110
  onProgress(`Mixcloud: ${result.url}`);
4151
4111
  }
4152
- if (doSetVideo) {
4153
- if (!options.video) {
4154
- throw new CliError2("missing_video", "--set-video needs --video <master.mp4>");
4155
- }
4156
- onProgress("Set video: staging the 1080p rendition…");
4157
- const { stageSetVideo: stageSetVideo2 } = await Promise.resolve().then(() => (init_mixtape_set_video(), exports_mixtape_set_video));
4158
- const result = await stageSetVideo2(mixtapeId, options.video, onProgress);
4159
- results.push({ platform: "set-video", url: result.url });
4160
- onProgress(`Set video: ${result.url}`);
4161
- }
4162
4112
  return { logId, mixtapeId, results };
4163
4113
  }
4164
4114
  async function mixtapeResyncCommand2(idOrLogId, options, onProgress = () => {}) {
@@ -4401,7 +4351,7 @@ async function resolveClipSource(clip) {
4401
4351
  throw new CliError2("mixtape_no_log_id", `Mixtape ${clip.mixtapeId} has no committed Log ID`);
4402
4352
  }
4403
4353
  if (!mixtape.setVideoAt) {
4404
- throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`distribute --set-video\` first`);
4354
+ throw new CliError2("set_not_staged", `Mixtape ${mixtape.logId} has no staged set video — run \`recordings promote <recordingId>\` first`);
4405
4355
  }
4406
4356
  return { setUrl: setVideoUrl(mixtape.logId) };
4407
4357
  }
@@ -7696,7 +7646,7 @@ function addAdminCommands(program2) {
7696
7646
  const { mixtapeGetCommand: mixtapeGetCommand2 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7697
7647
  await runMixtapeGet(idOrLogId, options, mixtapeGetCommand2);
7698
7648
  });
7699
- adminMixtapes.command("distribute").description("Mint + push a mixtape to YouTube (video) and Mixcloud (audio)").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--set-video", "Also stage a 1080p set-video rendition (from --video) to R2 + flip setVideoAt for the /log player").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7649
+ adminMixtapes.command("distribute").description("Push a promoted mixtape to YouTube (video) and Mixcloud (audio). The mixtape must already be promoted (`recordings promote`) \u2014 distribute is push-only.").argument("[idOrLogId]").option("--video <file>", "Video file for YouTube").option("--audio <file>", "Audio file for Mixcloud").option("--youtube", "Only distribute to YouTube").option("--mixcloud", "Only distribute to Mixcloud").option("--unlisted", "Keep Mixcloud private too (YouTube is always unlisted until publish-youtube)").option("--json", "Print JSON", false).allowExcessArguments().action(async (idOrLogId, options) => {
7700
7650
  const { mixtapeDistributeCommand: mixtapeDistributeCommand3 } = await Promise.resolve().then(() => (init_mixtapes(), exports_mixtapes));
7701
7651
  await runMixtapeDistribute(idOrLogId, options, mixtapeDistributeCommand3);
7702
7652
  });
package/package.json CHANGED
@@ -31,5 +31,5 @@
31
31
  "url": "git+https://github.com/mauricekleine/fluncle.git"
32
32
  },
33
33
  "type": "module",
34
- "version": "0.85.0"
34
+ "version": "0.86.0"
35
35
  }