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.
- package/bin/fluncle.mjs +192 -242
- 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.
|
|
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) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[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
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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(`
|
|
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 (!
|
|
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.
|
|
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] = `${
|
|
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",
|
|
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
|
|
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
|
|
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) => ({ '"': """, "&": "&", "'": "'", "<": "<", ">": ">" })[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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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(`
|
|
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 \`
|
|
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("
|
|
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