pi-multimodal-proxy 1.11.0 → 1.12.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/README.md +20 -0
- package/extensions/__tests__/internal.test.ts +77 -0
- package/extensions/internal.ts +58 -0
- package/extensions/vision-proxy.ts +175 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,6 +6,13 @@ When images are sent, this extension routes them to a **vision-capable model**,
|
|
|
6
6
|
|
|
7
7
|
When **video or audio files** are detected, they are routed to a **multimodal model** (default: Grok 4.3) that natively understands video content — transcribing speech with speaker diarization, describing visual scenes, reading on-screen text, and reasoning about the content — all in a single call.
|
|
8
8
|
|
|
9
|
+
**YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
|
|
10
|
+
|
|
11
|
+
## What's new in 1.12.0
|
|
12
|
+
|
|
13
|
+
- **YouTube video download** — paste a YouTube URL in your prompt and the extension downloads it via `yt-dlp` and analyzes it like any local video file. Gated by path detection (on by default); requires `yt-dlp` on your PATH. See [YouTube videos](#youtube-videos). After successful analysis you are prompted to optionally keep the downloaded file in your Downloads folder.
|
|
14
|
+
- **Save downloaded videos** — after analysis completes, the extension asks whether to save a copy to `~/Downloads` (sanitised filename, opt-in per file).
|
|
15
|
+
|
|
9
16
|
## What's new in 1.11.0
|
|
10
17
|
|
|
11
18
|
- **Global consent wildcard** — `/multimodal-proxy allowed-providers add *` (or `all`) grants consent for **all providers** globally, so you can consent once and never be prompted again. The wildcard (`*`) appears in the pre-consented list as "* (all providers)" and can be removed with `/multimodal-proxy allowed-providers remove *`. An explicit in-session `consent no` still beats the wildcard.
|
|
@@ -56,6 +63,19 @@ pi install npm:pi-multimodal-proxy
|
|
|
56
63
|
|
|
57
64
|
> **Upgrading from pi-vision-proxy?** Just install the new package. Your existing config is automatically migrated from `~/.pi/agent/vision-proxy.json`. The `/vision-proxy` command still works.
|
|
58
65
|
|
|
66
|
+
### YouTube videos
|
|
67
|
+
|
|
68
|
+
Pasting a YouTube URL downloads the video with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzes it through the normal video pipeline. Install yt-dlp once:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
winget install yt-dlp.yt-dlp # Windows
|
|
72
|
+
# or: choco install yt-dlp
|
|
73
|
+
# or: brew install yt-dlp # macOS
|
|
74
|
+
# or: pipx install yt-dlp # any OS with Python
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`ffmpeg` is also used (for duration probing and stream merging) — it usually ships alongside yt-dlp. Downloads are capped to ≤720p and rejected past the configured size limit (`PI_VISION_PROXY_MAX_VIDEO_BYTES`, default 200 MB). To disable URL auto-download, turn off path detection: `/multimodal-proxy path-detection off`.
|
|
78
|
+
|
|
59
79
|
## Modes
|
|
60
80
|
|
|
61
81
|
| Mode | Behavior |
|
|
@@ -35,6 +35,9 @@ import {
|
|
|
35
35
|
extractCandidateImagePaths,
|
|
36
36
|
extractCandidateVideoPaths,
|
|
37
37
|
extractCandidateAudioPaths,
|
|
38
|
+
extractCandidateMediaUrls,
|
|
39
|
+
canonicalYouTubeUrl,
|
|
40
|
+
youTubeVideoId,
|
|
38
41
|
extractDimensions,
|
|
39
42
|
fenceUntrusted,
|
|
40
43
|
findDescriptions,
|
|
@@ -964,6 +967,80 @@ describe("extractCandidateMediaPaths", () => {
|
|
|
964
967
|
});
|
|
965
968
|
});
|
|
966
969
|
|
|
970
|
+
describe("extractCandidateMediaUrls (YouTube)", () => {
|
|
971
|
+
it("extracts the video id from common URL forms", () => {
|
|
972
|
+
assert.equal(youTubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
973
|
+
assert.equal(youTubeVideoId("https://youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
974
|
+
assert.equal(youTubeVideoId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
975
|
+
assert.equal(youTubeVideoId("https://www.youtube.com/embed/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
976
|
+
assert.equal(youTubeVideoId("https://www.youtube.com/live/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
977
|
+
assert.equal(youTubeVideoId("https://m.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
978
|
+
assert.equal(youTubeVideoId("https://music.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("accepts scheme-less / bare URLs", () => {
|
|
982
|
+
assert.equal(youTubeVideoId("youtu.be/dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
983
|
+
assert.equal(youTubeVideoId("www.youtube.com/watch?v=dQw4w9WgXcQ"), "dQw4w9WgXcQ");
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("handles leading query params before v=", () => {
|
|
987
|
+
assert.equal(
|
|
988
|
+
youTubeVideoId("https://www.youtube.com/watch?app=desktop&v=dQw4w9WgXcQ&list=xyz"),
|
|
989
|
+
"dQw4w9WgXcQ",
|
|
990
|
+
);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("returns null for non-YouTube URLs", () => {
|
|
994
|
+
assert.equal(youTubeVideoId("https://vimeo.com/123456"), null);
|
|
995
|
+
assert.equal(youTubeVideoId("https://example.com/watch?v=abc"), null);
|
|
996
|
+
assert.equal(youTubeVideoId("not a url at all"), null);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("builds a canonical watch URL", () => {
|
|
1000
|
+
assert.equal(
|
|
1001
|
+
canonicalYouTubeUrl("https://youtu.be/dQw4w9WgXcQ?t=42"),
|
|
1002
|
+
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1003
|
+
);
|
|
1004
|
+
assert.equal(canonicalYouTubeUrl("https://vimeo.com/123"), null);
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it("detects YouTube URLs embedded in prompt text", () => {
|
|
1008
|
+
assert.deepEqual(
|
|
1009
|
+
extractCandidateMediaUrls("check this out https://www.youtube.com/watch?v=dQw4w9WgXcQ thanks"),
|
|
1010
|
+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ"],
|
|
1011
|
+
);
|
|
1012
|
+
assert.deepEqual(
|
|
1013
|
+
extractCandidateMediaUrls("see youtu.be/abcdefghij and a shorts https://youtube.com/shorts/123456789ab"),
|
|
1014
|
+
["youtu.be/abcdefghij", "https://youtube.com/shorts/123456789ab"],
|
|
1015
|
+
);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("captures trailing query/fragment so the whole URL can be stripped", () => {
|
|
1019
|
+
assert.deepEqual(
|
|
1020
|
+
extractCandidateMediaUrls("https://youtu.be/dQw4w9WgXcQ?t=42&feature=shared"),
|
|
1021
|
+
["https://youtu.be/dQw4w9WgXcQ?t=42&feature=shared"],
|
|
1022
|
+
);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it("does not swallow a trailing markdown link closer", () => {
|
|
1026
|
+
assert.deepEqual(
|
|
1027
|
+
extractCandidateMediaUrls("[video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)"),
|
|
1028
|
+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ"],
|
|
1029
|
+
);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it("deduplicates by video id across URL forms", () => {
|
|
1033
|
+
const out = extractCandidateMediaUrls(
|
|
1034
|
+
"https://youtu.be/dQw4w9WgXcQ and https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
1035
|
+
);
|
|
1036
|
+
assert.deepEqual(out, ["https://youtu.be/dQw4w9WgXcQ"]);
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it("ignores non-YouTube URLs", () => {
|
|
1040
|
+
assert.deepEqual(extractCandidateMediaUrls("see https://vimeo.com/123456 and https://example.com"), []);
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
967
1044
|
describe("stripMediaPaths", () => {
|
|
968
1045
|
it("replaces media paths with placeholder", () => {
|
|
969
1046
|
const mediaPath = "D:\\Downloads\\Rethinking Agents - Harness is All you Need_.mp4";
|
package/extensions/internal.ts
CHANGED
|
@@ -1382,6 +1382,64 @@ export function extractCandidateAudioPaths(text: string): string[] {
|
|
|
1382
1382
|
return extractCandidateMediaPaths(text, AUDIO_EXT_ALT);
|
|
1383
1383
|
}
|
|
1384
1384
|
|
|
1385
|
+
// ── Media URL detection (YouTube) ──────────────────────────────────────────
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Match a YouTube video URL and capture its video id (group 1).
|
|
1389
|
+
*
|
|
1390
|
+
* Supports youtu.be/<id>, youtube.com/watch?v=<id> (with optional leading
|
|
1391
|
+
* query params such as `app=desktop`), and the /shorts/, /embed/, /live/,
|
|
1392
|
+
* and /v/ path forms, on www., m., and music. subdomains. The scheme is
|
|
1393
|
+
* optional (a bare `youtu.be/<id>` is accepted). An optional trailing
|
|
1394
|
+
* query/fragment is consumed so the full matched text can be stripped from
|
|
1395
|
+
* the prompt. Build a `gi` instance from `.source` when scanning text.
|
|
1396
|
+
*/
|
|
1397
|
+
const YOUTUBE_URL_RE =
|
|
1398
|
+
/(?:https?:\/\/)?(?:www\.|m\.|music\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?(?:.*?&)?v=|shorts\/|embed\/|live\/|v\/))([A-Za-z0-9_-]{6,})(?:[?&][^\s"'<>()\[\]]*)?/i;
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* Extract the YouTube video id from a string. Returns null when the string
|
|
1402
|
+
* does not contain a YouTube video URL.
|
|
1403
|
+
*/
|
|
1404
|
+
export function youTubeVideoId(s: string): string | null {
|
|
1405
|
+
const m = s.match(YOUTUBE_URL_RE);
|
|
1406
|
+
return m?.[1] ?? null;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Return the canonical `https://www.youtube.com/watch?v=<id>` form for a
|
|
1411
|
+
* YouTube URL. Returns null when the input is not a YouTube video URL.
|
|
1412
|
+
* yt-dlp only needs the canonical watch URL — timestamp/playlist params are
|
|
1413
|
+
* not required to fetch the video.
|
|
1414
|
+
*/
|
|
1415
|
+
export function canonicalYouTubeUrl(s: string): string | null {
|
|
1416
|
+
const id = youTubeVideoId(s);
|
|
1417
|
+
return id ? `https://www.youtube.com/watch?v=${id}` : null;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* Extract candidate YouTube video URLs from prompt text.
|
|
1422
|
+
*
|
|
1423
|
+
* Returns the matched substrings exactly as they appear so they can be
|
|
1424
|
+
* stripped from the prompt, de-duplicated by video id. Pass each result to
|
|
1425
|
+
* `canonicalYouTubeUrl()` to obtain the yt-dlp input. Non-YouTube URLs are
|
|
1426
|
+
* ignored (other providers can be added later by extending the regex).
|
|
1427
|
+
*/
|
|
1428
|
+
export function extractCandidateMediaUrls(text: string): string[] {
|
|
1429
|
+
const out: string[] = [];
|
|
1430
|
+
const seen = new Set<string>();
|
|
1431
|
+
const re = new RegExp(YOUTUBE_URL_RE.source, "gi");
|
|
1432
|
+
let m: RegExpExecArray | null;
|
|
1433
|
+
while ((m = re.exec(text)) !== null) {
|
|
1434
|
+
const id = m[1];
|
|
1435
|
+
if (id && !seen.has(id)) {
|
|
1436
|
+
seen.add(id);
|
|
1437
|
+
out.push(m[0]);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
return out;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1385
1443
|
/**
|
|
1386
1444
|
* Extract candidate image file paths from prompt text.
|
|
1387
1445
|
* Matches `pi-clipboard-*` temp files and general paths ending with image extensions.
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { execFile } from "node:child_process";
|
|
46
|
-
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
46
|
+
import { copyFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
|
47
47
|
import os from "node:os";
|
|
48
48
|
import { isAbsolute, join } from "node:path";
|
|
49
49
|
import { promisify } from "node:util";
|
|
@@ -96,6 +96,9 @@ import {
|
|
|
96
96
|
extractCandidateImagePaths,
|
|
97
97
|
extractCandidateVideoPaths,
|
|
98
98
|
extractCandidateAudioPaths,
|
|
99
|
+
extractCandidateMediaUrls,
|
|
100
|
+
canonicalYouTubeUrl,
|
|
101
|
+
youTubeVideoId,
|
|
99
102
|
applyDefaultModelFallback,
|
|
100
103
|
applyRecallCompletion,
|
|
101
104
|
buildRecallItems,
|
|
@@ -929,6 +932,106 @@ async function extractAudioChunkToMp3(inputPath: string, outputPath: string, sta
|
|
|
929
932
|
], { windowsHide: true, timeout: 120_000 });
|
|
930
933
|
}
|
|
931
934
|
|
|
935
|
+
// ── yt-dlp: download media URLs (YouTube) ───────────────────────────────────
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Cache yt-dlp availability so a missing binary is reported once per session
|
|
939
|
+
* rather than every turn.
|
|
940
|
+
*/
|
|
941
|
+
let ytDlpAvailable: boolean | null = null;
|
|
942
|
+
|
|
943
|
+
async function checkYtDlp(): Promise<boolean> {
|
|
944
|
+
if (ytDlpAvailable !== null) return ytDlpAvailable;
|
|
945
|
+
try {
|
|
946
|
+
await execFileAsync("yt-dlp", ["--version"], { windowsHide: true, timeout: 15_000 });
|
|
947
|
+
ytDlpAvailable = true;
|
|
948
|
+
} catch {
|
|
949
|
+
ytDlpAvailable = false;
|
|
950
|
+
}
|
|
951
|
+
return ytDlpAvailable;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
interface DownloadedMedia {
|
|
955
|
+
/** Final on-disk path of the downloaded file. */
|
|
956
|
+
path: string;
|
|
957
|
+
/** Temp dir holding the file; the caller must remove it. */
|
|
958
|
+
tempDir: string;
|
|
959
|
+
/** Human-readable label (video title) for notifications / fences. */
|
|
960
|
+
title: string;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Download a media URL via yt-dlp into a fresh temp dir.
|
|
965
|
+
*
|
|
966
|
+
* Prefers an already-muxed mp4 at <=720p to keep the payload small for the
|
|
967
|
+
* video model; falls back to the best available stream merged to mp4. The
|
|
968
|
+
* post-read size guard (maxVideoFileBytes) still applies, so oversized
|
|
969
|
+
* downloads are rejected downstream with a clear "too-large" reason.
|
|
970
|
+
*
|
|
971
|
+
* Returns null (with a user-facing notification) when yt-dlp is missing or
|
|
972
|
+
* the download fails. The caller owns tempDir cleanup.
|
|
973
|
+
*/
|
|
974
|
+
async function downloadMediaUrl(
|
|
975
|
+
url: string,
|
|
976
|
+
signal: AbortSignal | undefined,
|
|
977
|
+
ctx: ExtensionContext,
|
|
978
|
+
): Promise<DownloadedMedia | null> {
|
|
979
|
+
if (!(await checkYtDlp())) {
|
|
980
|
+
ctx.ui.notify(
|
|
981
|
+
"[multimodal-proxy] YouTube download skipped — yt-dlp not found. Install it (e.g. `winget install yt-dlp.yt-dlp` or `choco install yt-dlp`) and retry.",
|
|
982
|
+
"warning",
|
|
983
|
+
);
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const tempDir = await mkdtemp(join(os.tmpdir(), "multimodal-proxy-ytdl-"));
|
|
988
|
+
try {
|
|
989
|
+
// NOTE on `--print after_move:filepath`: when every --print field is a
|
|
990
|
+
// pre-download metadata field (e.g. only "%(title)s"), yt-dlp skips the
|
|
991
|
+
// actual download — it can satisfy the print from metadata alone. Adding
|
|
992
|
+
// an after_move field forces the download (it is only known once the file
|
|
993
|
+
// is in its final location) and also hands us the exact output path.
|
|
994
|
+
const { stdout } = await execFileAsync(
|
|
995
|
+
"yt-dlp",
|
|
996
|
+
[
|
|
997
|
+
"--no-playlist",
|
|
998
|
+
"--no-warnings",
|
|
999
|
+
"--no-progress",
|
|
1000
|
+
"-f", "best[ext=mp4][height<=720]/best[height<=720]/best",
|
|
1001
|
+
"--merge-output-format", "mp4",
|
|
1002
|
+
"-o", join(tempDir, "%(id)s.%(ext)s"),
|
|
1003
|
+
"--print", "%(title)s",
|
|
1004
|
+
"--print", "after_move:filepath",
|
|
1005
|
+
url,
|
|
1006
|
+
],
|
|
1007
|
+
{ windowsHide: true, timeout: 240_000, maxBuffer: 4 * 1024 * 1024, signal },
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
const lines = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1011
|
+
// The filepath line is the one that looks like an absolute path.
|
|
1012
|
+
const pathLine = lines.find((l) => /^([a-zA-Z]:[\\/]|[\\/])/.test(l));
|
|
1013
|
+
const title = (lines.find((l) => l !== pathLine) ?? "").trim() || url;
|
|
1014
|
+
|
|
1015
|
+
// Prefer the path yt-dlp reported; fall back to scanning the temp dir.
|
|
1016
|
+
let path = pathLine ?? "";
|
|
1017
|
+
if (!path) {
|
|
1018
|
+
const entries = await readdir(tempDir);
|
|
1019
|
+
const file = entries.find((f) => !f.endsWith(".part") && !f.endsWith(".ytdl"));
|
|
1020
|
+
path = file ? join(tempDir, file) : "";
|
|
1021
|
+
}
|
|
1022
|
+
if (!path) {
|
|
1023
|
+
ctx.ui.notify(`[multimodal-proxy] YouTube download produced no file for ${url}`, "warning");
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
return { path, tempDir, title };
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
if (signal?.aborted) return null;
|
|
1029
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1030
|
+
ctx.ui.notify(`[multimodal-proxy] YouTube download failed for ${url}: ${msg}`, "warning");
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
932
1035
|
async function analyzeVideoViaXaiStt(
|
|
933
1036
|
mediaFile: { type: "image"; data: string; mimeType: string },
|
|
934
1037
|
filename: string,
|
|
@@ -1566,6 +1669,52 @@ export default function (pi: ExtensionAPI) {
|
|
|
1566
1669
|
event.prompt = stripMediaPaths(event.prompt, acceptedMediaPaths);
|
|
1567
1670
|
}
|
|
1568
1671
|
|
|
1672
|
+
// ── Detect & download media URLs (YouTube, etc.) ─────────────────
|
|
1673
|
+
// Reuses the local-file pipeline: download to a temp dir, then read it
|
|
1674
|
+
// like any other media file. Gated by path detection (on by default) —
|
|
1675
|
+
// no separate switch. Requires yt-dlp on PATH (ffmpeg is already used
|
|
1676
|
+
// elsewhere by this extension).
|
|
1677
|
+
const downloadedTempDirs: string[] = [];
|
|
1678
|
+
const downloadedFiles = new Map<string, { title: string; tempDir: string; filename: string; size: number }>();
|
|
1679
|
+
const cleanupDownloads = async () => {
|
|
1680
|
+
for (const d of downloadedTempDirs) {
|
|
1681
|
+
try { await rm(d, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1682
|
+
}
|
|
1683
|
+
downloadedTempDirs.length = 0;
|
|
1684
|
+
};
|
|
1685
|
+
if (pathDetectionOn) {
|
|
1686
|
+
const mediaUrls = extractCandidateMediaUrls(event.prompt);
|
|
1687
|
+
const acceptedMediaUrls: string[] = [];
|
|
1688
|
+
for (const url of mediaUrls) {
|
|
1689
|
+
const watch = canonicalYouTubeUrl(url);
|
|
1690
|
+
if (!watch) continue;
|
|
1691
|
+
const id = youTubeVideoId(url) ?? "";
|
|
1692
|
+
const dl = await withProgress(
|
|
1693
|
+
ctx,
|
|
1694
|
+
() => `Downloading YouTube ${id}…`,
|
|
1695
|
+
`Downloading YouTube ${id}…`,
|
|
1696
|
+
() => downloadMediaUrl(watch, ctx.signal, ctx),
|
|
1697
|
+
);
|
|
1698
|
+
if (!dl) continue;
|
|
1699
|
+
downloadedTempDirs.push(dl.tempDir);
|
|
1700
|
+
const r = await readMediaFileWithReason(dl.path, pathAccess);
|
|
1701
|
+
if (r.media) {
|
|
1702
|
+
const fname = r.filename ?? dl.title;
|
|
1703
|
+
downloadedFiles.set(dl.path, { title: dl.title, tempDir: dl.tempDir, filename: fname, size: r.bytes ?? 0 });
|
|
1704
|
+
mediaFiles.push({ file: r.media, filename: fname, path: dl.path });
|
|
1705
|
+
acceptedMediaUrls.push(url);
|
|
1706
|
+
} else if (r.reason && r.reason !== "not-a-media") {
|
|
1707
|
+
ctx.ui.notify(
|
|
1708
|
+
`[multimodal-proxy] Skipped YouTube ${id}: ${describeReadMediaReason(r.reason, r.bytes)}`,
|
|
1709
|
+
"warning",
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (acceptedMediaUrls.length > 0) {
|
|
1714
|
+
event.prompt = stripMediaPaths(event.prompt, acceptedMediaUrls);
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1569
1718
|
// Inject loaded file-path images into the event so they reach the model
|
|
1570
1719
|
// regardless of whether vision-proxy stripping runs. Strip paths from the
|
|
1571
1720
|
// prompt text to avoid duplicate references.
|
|
@@ -1582,10 +1731,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1582
1731
|
|
|
1583
1732
|
// ── Handle video/audio files ─────────────────────────────────────
|
|
1584
1733
|
let videoDescriptionFence = "";
|
|
1734
|
+
const videoResults: VideoAnalysisResult[] = [];
|
|
1585
1735
|
if (mediaFiles.length > 0 && config.mode !== "off") {
|
|
1586
1736
|
// Check consent for video provider
|
|
1587
1737
|
if (!(await ensureConsent({ ...config, provider: config.videoProvider }, ctx, entries, pi))) {
|
|
1588
1738
|
ctx.ui.notify("[multimodal-proxy] Video analysis skipped - no consent.", "warning");
|
|
1739
|
+
await cleanupDownloads();
|
|
1589
1740
|
// Inject actionable message so the agent tells the user what to do
|
|
1590
1741
|
return {
|
|
1591
1742
|
systemPrompt:
|
|
@@ -1597,8 +1748,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1597
1748
|
")",
|
|
1598
1749
|
};
|
|
1599
1750
|
} else {
|
|
1600
|
-
|
|
1601
|
-
for (const [mi, mf] of mediaFiles.entries()) {
|
|
1751
|
+
for (const [mi, mf] of mediaFiles.entries()) {
|
|
1602
1752
|
const label = () =>
|
|
1603
1753
|
mediaFiles.length > 1
|
|
1604
1754
|
? `Analyzing ${mf.filename} (${mi + 1}/${mediaFiles.length})…`
|
|
@@ -1653,6 +1803,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
1653
1803
|
}
|
|
1654
1804
|
}
|
|
1655
1805
|
}
|
|
1806
|
+
// Offer to save successfully analyzed downloaded videos
|
|
1807
|
+
for (const [path, info] of downloadedFiles) {
|
|
1808
|
+
const result = videoResults.find(r => r.filename === info.filename && r.description);
|
|
1809
|
+
if (!result) continue;
|
|
1810
|
+
const sizeMB = (info.size / (1024 * 1024)).toFixed(1);
|
|
1811
|
+
try {
|
|
1812
|
+
const save = await ctx.ui.confirm(
|
|
1813
|
+
"Save downloaded video?",
|
|
1814
|
+
`"${info.title}"\n\n${sizeMB} MB — save to your Downloads folder?`,
|
|
1815
|
+
);
|
|
1816
|
+
if (save) {
|
|
1817
|
+
const safeTitle = info.title.replace(/[<>:"\/\\|?*]/g, "_").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1818
|
+
const dest = join(os.homedir(), "Downloads", `${safeTitle}.mp4`);
|
|
1819
|
+
await copyFile(path, dest);
|
|
1820
|
+
ctx.ui.notify(`[multimodal-proxy] ✓ Saved "${safeTitle}.mp4" to Downloads`, "info");
|
|
1821
|
+
}
|
|
1822
|
+
} catch {
|
|
1823
|
+
// Don't block the agent on save errors — temp files are
|
|
1824
|
+
// cleaned below regardless.
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
await cleanupDownloads();
|
|
1656
1828
|
|
|
1657
1829
|
// ── Handle images (existing flow) ──────────────────────────────────
|
|
1658
1830
|
if (images.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multimodal-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|