dsh-vidi 0.8.1 → 0.8.3
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/lib/client.js +90 -28
- package/lib/index.mjs +118 -1
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -35281,19 +35281,6 @@ function useDevices(enabled) {
|
|
|
35281
35281
|
}, [enabled, devices]);
|
|
35282
35282
|
return devices;
|
|
35283
35283
|
}
|
|
35284
|
-
/**
|
|
35285
|
-
* Ask for the camera / microphone from the page first when the page runs on the same computer: the system
|
|
35286
|
-
* then asks the user once for this app, and the capture that follows is allowed.
|
|
35287
|
-
*/
|
|
35288
|
-
async function askDeviceAccess(video) {
|
|
35289
|
-
if (!(location.protocol === "dsh-app:" || /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname)) || !navigator.mediaDevices?.getUserMedia) return;
|
|
35290
|
-
try {
|
|
35291
|
-
(await navigator.mediaDevices.getUserMedia({
|
|
35292
|
-
audio: true,
|
|
35293
|
-
video
|
|
35294
|
-
})).getTracks().forEach((tr) => tr.stop());
|
|
35295
|
-
} catch {}
|
|
35296
|
-
}
|
|
35297
35284
|
/** The push stream key: copy it, or change it (typed, or a new random one). */
|
|
35298
35285
|
function KeyRow({ value, t, onChanged }) {
|
|
35299
35286
|
const [edit, setEdit] = (0, react.useState)(null);
|
|
@@ -35488,7 +35475,9 @@ function LiveView(props) {
|
|
|
35488
35475
|
setSrcTabRaw(v);
|
|
35489
35476
|
setError("");
|
|
35490
35477
|
};
|
|
35491
|
-
const devices = useDevices(srcTab === "camera");
|
|
35478
|
+
const devices = useDevices(srcTab === "camera" || job?.source === "device");
|
|
35479
|
+
const [accessIssue, setAccessIssue] = (0, react.useState)(null);
|
|
35480
|
+
const [asking, setAsking] = (0, react.useState)(false);
|
|
35492
35481
|
const [camVideo, setCamVideoRaw] = (0, react.useState)(() => store.get("device.video", ""));
|
|
35493
35482
|
const [camAudio, setCamAudioRaw] = (0, react.useState)(() => store.get("device.audio", ""));
|
|
35494
35483
|
const setCamVideo = (v) => {
|
|
@@ -35968,8 +35957,8 @@ function LiveView(props) {
|
|
|
35968
35957
|
const needsModel = !hasTranslator && shownLines.some((l) => l !== "src");
|
|
35969
35958
|
const hasMain = !!job && !(job.status === "error" && !job.files.length);
|
|
35970
35959
|
const cams = (devices ?? []).filter((d) => d.kind !== "mic"), mics = (devices ?? []).filter((d) => d.kind === "mic");
|
|
35971
|
-
const camV = camVideo === "none" || cams.some((d) => d.id === camVideo) ? camVideo : cams.find((d) => d.kind === "camera")?.id ?? "none";
|
|
35972
|
-
const camA = mics.some((d) => d.id === camAudio) ? camAudio : mics[0]?.id ?? "";
|
|
35960
|
+
const camV = camVideo === "none" || cams.some((d) => d.id === camVideo) ? camVideo : cams.find((d) => d.preferred)?.id ?? cams.find((d) => d.kind === "camera")?.id ?? "none";
|
|
35961
|
+
const camA = mics.some((d) => d.id === camAudio) ? camAudio : mics.find((d) => d.preferred)?.id ?? mics[0]?.id ?? "";
|
|
35973
35962
|
const videoOptions = [...cams.map((d) => ({
|
|
35974
35963
|
value: d.id,
|
|
35975
35964
|
label: d.kind === "screen" ? t("dev.screen").replace("{n}", String(Number(/\d+/.exec(d.name)?.[0] ?? 0) + 1)) : d.name,
|
|
@@ -35982,13 +35971,48 @@ function LiveView(props) {
|
|
|
35982
35971
|
const startDevice = async () => {
|
|
35983
35972
|
if (!camA) return;
|
|
35984
35973
|
const title = camV === "none" ? mics.find((d) => d.id === camA)?.name ?? "" : videoOptions.find((o) => o.value === camV)?.label ?? "";
|
|
35985
|
-
|
|
35974
|
+
const kinds = ["microphone", ...cams.find((d) => d.id === camV)?.kind === "camera" ? ["camera"] : []];
|
|
35975
|
+
setAccessIssue(null);
|
|
35976
|
+
try {
|
|
35977
|
+
let st = await (await fetch("/live/api/access")).json();
|
|
35978
|
+
for (const k of kinds) {
|
|
35979
|
+
if (st[k] !== "not-determined") continue;
|
|
35980
|
+
setAsking(true);
|
|
35981
|
+
st = {
|
|
35982
|
+
...st,
|
|
35983
|
+
[k]: (await post("access", {
|
|
35984
|
+
kind: k,
|
|
35985
|
+
action: "ask"
|
|
35986
|
+
})).state
|
|
35987
|
+
};
|
|
35988
|
+
}
|
|
35989
|
+
setAsking(false);
|
|
35990
|
+
const blocked = kinds.filter((k) => st[k] === "denied" || st[k] === "restricted");
|
|
35991
|
+
if (blocked.length) {
|
|
35992
|
+
setAccessIssue(blocked);
|
|
35993
|
+
return;
|
|
35994
|
+
}
|
|
35995
|
+
} catch {
|
|
35996
|
+
setAsking(false);
|
|
35997
|
+
}
|
|
35986
35998
|
start(JSON.stringify({
|
|
35987
35999
|
video: camV === "none" ? void 0 : camV,
|
|
35988
36000
|
audio: camA,
|
|
35989
36001
|
title
|
|
35990
36002
|
}), "device");
|
|
35991
36003
|
};
|
|
36004
|
+
const openPrivacy = (kind) => void post("access", {
|
|
36005
|
+
kind,
|
|
36006
|
+
action: "settings"
|
|
36007
|
+
}).catch(() => {});
|
|
36008
|
+
const blockedKind = () => {
|
|
36009
|
+
try {
|
|
36010
|
+
const c = JSON.parse(job?.url ?? "{}");
|
|
36011
|
+
return cams.find((d) => d.id === c.video)?.kind === "screen" ? "screen" : "camera";
|
|
36012
|
+
} catch {
|
|
36013
|
+
return "camera";
|
|
36014
|
+
}
|
|
36015
|
+
};
|
|
35992
36016
|
const emptyBox = () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
35993
36017
|
className: "dshl-drop",
|
|
35994
36018
|
children: [
|
|
@@ -36292,8 +36316,8 @@ function LiveView(props) {
|
|
|
36292
36316
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36293
36317
|
className: "dshl-btn primary",
|
|
36294
36318
|
onClick: () => void startDevice(),
|
|
36295
|
-
disabled: !camA,
|
|
36296
|
-
children: t("dev.start")
|
|
36319
|
+
disabled: !camA || asking,
|
|
36320
|
+
children: asking ? t("dev.asking") : t("dev.start")
|
|
36297
36321
|
})] }),
|
|
36298
36322
|
srcTab === "push" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
36299
36323
|
className: "dshl-field static",
|
|
@@ -36390,13 +36414,37 @@ function LiveView(props) {
|
|
|
36390
36414
|
className: "dshl-note",
|
|
36391
36415
|
children: t("note.noModel")
|
|
36392
36416
|
}),
|
|
36393
|
-
|
|
36417
|
+
accessIssue && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36394
36418
|
className: "dshl-note warn",
|
|
36395
|
-
children: [
|
|
36396
|
-
|
|
36397
|
-
|
|
36398
|
-
|
|
36399
|
-
|
|
36419
|
+
children: [
|
|
36420
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("dev.denied").replace("{d}", accessIssue.map((k) => t(`dev.k.${k}`)).join(t("dev.and"))) }),
|
|
36421
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36422
|
+
className: "dshl-link",
|
|
36423
|
+
onClick: () => openPrivacy(accessIssue[0]),
|
|
36424
|
+
children: t("dev.openSettings")
|
|
36425
|
+
}),
|
|
36426
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36427
|
+
className: "dshl-link",
|
|
36428
|
+
onClick: () => void startDevice(),
|
|
36429
|
+
children: t("st.retry")
|
|
36430
|
+
})
|
|
36431
|
+
]
|
|
36432
|
+
}),
|
|
36433
|
+
!accessIssue && (error || job?.status === "error") && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
36434
|
+
className: "dshl-note warn",
|
|
36435
|
+
children: [
|
|
36436
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: error || (job?.message === "device-blocked" ? t("dev.blocked") : job?.message) }),
|
|
36437
|
+
job?.message === "device-blocked" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36438
|
+
className: "dshl-link",
|
|
36439
|
+
onClick: () => openPrivacy(blockedKind()),
|
|
36440
|
+
children: t("dev.openSettings")
|
|
36441
|
+
}),
|
|
36442
|
+
job && !active && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
36443
|
+
className: "dshl-link",
|
|
36444
|
+
onClick: () => start(job.url, job.source),
|
|
36445
|
+
children: t("st.retry")
|
|
36446
|
+
})
|
|
36447
|
+
]
|
|
36400
36448
|
}),
|
|
36401
36449
|
!hasMain ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("section", {
|
|
36402
36450
|
className: "dshl-empty",
|
|
@@ -37305,7 +37353,14 @@ const en = {
|
|
|
37305
37353
|
"src.push.bar": "Waiting for OBS: server and key below",
|
|
37306
37354
|
"src.push.wait": "Getting the address ready…",
|
|
37307
37355
|
"src.push.busy": "Close the current video to receive a stream",
|
|
37308
|
-
"dev.blocked": "No picture or sound from the device
|
|
37356
|
+
"dev.blocked": "No picture or sound from the device: DeepSeek Harness needs the system’s permission. Open System Settings, switch DeepSeek Harness on, then retry.",
|
|
37357
|
+
"dev.denied": "DeepSeek Harness needs permission to use the {d}. Open System Settings, switch it on in the list, then retry.",
|
|
37358
|
+
"dev.and": " and ",
|
|
37359
|
+
"dev.k.camera": "camera",
|
|
37360
|
+
"dev.k.microphone": "microphone",
|
|
37361
|
+
"dev.k.screen": "screen",
|
|
37362
|
+
"dev.openSettings": "Open System Settings",
|
|
37363
|
+
"dev.asking": "Allow it in the system dialog…",
|
|
37309
37364
|
"dev.video": "Picture",
|
|
37310
37365
|
"dev.audio": "Microphone",
|
|
37311
37366
|
"dev.cameras": "Cameras",
|
|
@@ -37736,7 +37791,14 @@ const zh = {
|
|
|
37736
37791
|
"src.push.bar": "等 OBS 推流过来,服务器和密钥在下面",
|
|
37737
37792
|
"src.push.wait": "正在准备推流地址…",
|
|
37738
37793
|
"src.push.busy": "关掉当前视频后可以接收推流",
|
|
37739
|
-
"dev.blocked": "
|
|
37794
|
+
"dev.blocked": "设备没有传来画面和声音:DeepSeek Harness 需要系统授权。点「打开系统设置」,在列表里打开 DeepSeek Harness,再点重试。",
|
|
37795
|
+
"dev.denied": "DeepSeek Harness 需要{d}权限。点「打开系统设置」,在列表里打开它,再点重试。",
|
|
37796
|
+
"dev.and": "和",
|
|
37797
|
+
"dev.k.camera": "摄像头",
|
|
37798
|
+
"dev.k.microphone": "麦克风",
|
|
37799
|
+
"dev.k.screen": "屏幕录制",
|
|
37800
|
+
"dev.openSettings": "打开系统设置",
|
|
37801
|
+
"dev.asking": "请在系统弹窗里点允许…",
|
|
37740
37802
|
"dev.video": "画面",
|
|
37741
37803
|
"dev.audio": "麦克风",
|
|
37742
37804
|
"dev.cameras": "摄像头",
|
|
@@ -38189,7 +38251,7 @@ html[data-platform='darwin']:not([data-fullscreen]) .dshl-pagewrap .dshl.page .d
|
|
|
38189
38251
|
html:not([data-platform]) [data-sidebar-drawer] .dshl-panelwrap .dshl.page .dshl-top { padding-left: 12px; }
|
|
38190
38252
|
/* macOS: the app's window controls (traffic lights, sidebar toggle, new chat) are centred at y 25 in
|
|
38191
38253
|
every state, full screen included; the bar's 40px row is centred on the same line. */
|
|
38192
|
-
html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top:
|
|
38254
|
+
html[data-platform='darwin'] .dshl-panelwrap .dshl.page .dshl-top { padding-top: 12px; padding-bottom: 12px; }
|
|
38193
38255
|
html[data-platform='darwin'] .dshl.page .dshl-top { -webkit-app-region: drag; app-region: drag; }
|
|
38194
38256
|
html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .dshl-select, .dshl-pick) { -webkit-app-region: no-drag; app-region: no-drag; }
|
|
38195
38257
|
.dshl.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
package/lib/index.mjs
CHANGED
|
@@ -1829,7 +1829,7 @@ async function listDevices() {
|
|
|
1829
1829
|
kind: /^Capture screen/.test(name) ? "screen" : "camera"
|
|
1830
1830
|
});
|
|
1831
1831
|
}
|
|
1832
|
-
return out.filter((d) => !(d.kind === "mic" && /speakers|audio device|blackhole|soundflower|loopback/i.test(d.name)));
|
|
1832
|
+
return preferLocal(out.filter((d) => !(d.kind === "mic" && /speakers|audio device|streaming|virtual|blackhole|soundflower|loopback/i.test(d.name))), await defaultInput());
|
|
1833
1833
|
}
|
|
1834
1834
|
if (process.platform === "win32") {
|
|
1835
1835
|
const text = await ffmpegList([
|
|
@@ -1873,6 +1873,39 @@ async function listDevices() {
|
|
|
1873
1873
|
kind: "mic"
|
|
1874
1874
|
}];
|
|
1875
1875
|
}
|
|
1876
|
+
/** The system's default input device (macOS), by name. */
|
|
1877
|
+
function defaultInput() {
|
|
1878
|
+
return new Promise((resolve) => {
|
|
1879
|
+
execFile("system_profiler", ["SPAudioDataType", "-json"], { timeout: 8e3 }, (err, out) => {
|
|
1880
|
+
if (err) return resolve(void 0);
|
|
1881
|
+
try {
|
|
1882
|
+
resolve((JSON.parse(String(out)).SPAudioDataType?.[0]?._items ?? []).find((i) => i.coreaudio_default_audio_input_device === "spaudio_yes")?._name);
|
|
1883
|
+
} catch {
|
|
1884
|
+
resolve(void 0);
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Mark (and list first) this computer's own camera and the default microphone, ahead of a phone's camera
|
|
1891
|
+
* through Continuity ("“Name”的相机", "Name's Camera") or a desk-view camera.
|
|
1892
|
+
*/
|
|
1893
|
+
function preferLocal(list, defaultMic) {
|
|
1894
|
+
const phone = (d) => /^[“"].*[”"]/.test(d.name) || /['’]s (camera|microphone)/i.test(d.name);
|
|
1895
|
+
const deskView = (d) => /桌上视角|desk view/i.test(d.name);
|
|
1896
|
+
const cams = list.filter((d) => d.kind === "camera");
|
|
1897
|
+
const cam = cams.find((d) => !phone(d) && !deskView(d) && /macbook|imac|facetime|built-in|内建|display/i.test(d.name)) ?? cams.find((d) => !phone(d) && !deskView(d)) ?? cams[0];
|
|
1898
|
+
const mics = list.filter((d) => d.kind === "mic");
|
|
1899
|
+
const mic = mics.find((d) => d.name === defaultMic) ?? mics.find((d) => !phone(d)) ?? mics[0];
|
|
1900
|
+
for (const d of [cam, mic]) if (d) d.preferred = true;
|
|
1901
|
+
const rank = (d) => d.preferred ? 0 : phone(d) || deskView(d) ? 2 : 1;
|
|
1902
|
+
const group = {
|
|
1903
|
+
camera: 0,
|
|
1904
|
+
screen: 1,
|
|
1905
|
+
mic: 2
|
|
1906
|
+
};
|
|
1907
|
+
return [...list].sort((a, b) => group[a.kind] - group[b.kind] || rank(a) - rank(b));
|
|
1908
|
+
}
|
|
1876
1909
|
/** ffmpeg input for the chosen devices: arguments plus which streams carry the picture and the sound. */
|
|
1877
1910
|
function deviceInput(c, q) {
|
|
1878
1911
|
if (!c.audio) throw new Error("choose a microphone");
|
|
@@ -5778,6 +5811,82 @@ function metered(llm, meter) {
|
|
|
5778
5811
|
}
|
|
5779
5812
|
};
|
|
5780
5813
|
}
|
|
5814
|
+
// src/access.ts
|
|
5815
|
+
const LOAD = "$.NSBundle.bundleWithPath(\"/System/Library/Frameworks/AVFoundation.framework\").load; const D = $.NSClassFromString(\"AVCaptureDevice\");";
|
|
5816
|
+
const MEDIA = {
|
|
5817
|
+
camera: "vide",
|
|
5818
|
+
microphone: "soun"
|
|
5819
|
+
};
|
|
5820
|
+
const STATES = [
|
|
5821
|
+
"not-determined",
|
|
5822
|
+
"restricted",
|
|
5823
|
+
"denied",
|
|
5824
|
+
"granted"
|
|
5825
|
+
];
|
|
5826
|
+
function jxa(script, timeout) {
|
|
5827
|
+
return new Promise((resolve) => {
|
|
5828
|
+
execFile("osascript", [
|
|
5829
|
+
"-l",
|
|
5830
|
+
"JavaScript",
|
|
5831
|
+
"-e",
|
|
5832
|
+
script
|
|
5833
|
+
], { timeout }, (err, out) => resolve(err ? "" : String(out).trim()));
|
|
5834
|
+
});
|
|
5835
|
+
}
|
|
5836
|
+
/** This app's camera and microphone permission (macOS); 'unknown' elsewhere. */
|
|
5837
|
+
async function accessStatus() {
|
|
5838
|
+
if (process.platform !== "darwin") return {
|
|
5839
|
+
camera: "unknown",
|
|
5840
|
+
microphone: "unknown"
|
|
5841
|
+
};
|
|
5842
|
+
const out = await jxa(`${LOAD} JSON.stringify({ camera: D.authorizationStatusForMediaType("vide"), microphone: D.authorizationStatusForMediaType("soun") })`, 1e4);
|
|
5843
|
+
try {
|
|
5844
|
+
const j = JSON.parse(out);
|
|
5845
|
+
return {
|
|
5846
|
+
camera: STATES[Number(j.camera)] ?? "unknown",
|
|
5847
|
+
microphone: STATES[Number(j.microphone)] ?? "unknown"
|
|
5848
|
+
};
|
|
5849
|
+
} catch {
|
|
5850
|
+
return {
|
|
5851
|
+
camera: "unknown",
|
|
5852
|
+
microphone: "unknown"
|
|
5853
|
+
};
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
/** Ask once (the system dialog); answers the state afterwards. Already decided: just the state. */
|
|
5857
|
+
async function askAccess(kind) {
|
|
5858
|
+
if (process.platform !== "darwin") return "unknown";
|
|
5859
|
+
const before = (await accessStatus())[kind];
|
|
5860
|
+
if (before !== "not-determined") return before;
|
|
5861
|
+
await jxa(`${LOAD} let done = null; D.requestAccessForMediaTypeCompletionHandler("${MEDIA[kind]}", ok => { done = ok });
|
|
5862
|
+
const end = Date.now() + 120000;
|
|
5863
|
+
while (done === null && Date.now() < end) $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.2));
|
|
5864
|
+
String(done)`, 13e4);
|
|
5865
|
+
return (await accessStatus())[kind];
|
|
5866
|
+
}
|
|
5867
|
+
/** Open the system's privacy page for this kind of device, where the app can be switched on. */
|
|
5868
|
+
function openPrivacySettings(kind) {
|
|
5869
|
+
if (process.platform === "darwin") {
|
|
5870
|
+
spawn("open", [`x-apple.systempreferences:com.apple.preference.security?${kind === "camera" ? "Privacy_Camera" : kind === "microphone" ? "Privacy_Microphone" : "Privacy_ScreenCapture"}`], {
|
|
5871
|
+
stdio: "ignore",
|
|
5872
|
+
detached: true
|
|
5873
|
+
}).unref();
|
|
5874
|
+
return true;
|
|
5875
|
+
}
|
|
5876
|
+
if (process.platform === "win32") {
|
|
5877
|
+
spawn("cmd", [
|
|
5878
|
+
"/c",
|
|
5879
|
+
"start",
|
|
5880
|
+
"",
|
|
5881
|
+
`ms-settings:${kind === "camera" ? "privacy-webcam" : kind === "microphone" ? "privacy-microphone" : "privacy"}`
|
|
5882
|
+
], {
|
|
5883
|
+
stdio: "ignore",
|
|
5884
|
+
detached: true
|
|
5885
|
+
}).unref();
|
|
5886
|
+
return true;
|
|
5887
|
+
}
|
|
5888
|
+
return false;
|
|
5889
|
+
}
|
|
5781
5890
|
// src/updates.ts
|
|
5782
5891
|
const DAY = 864e5;
|
|
5783
5892
|
/** pnpm holds back releases younger than a day: offer only versions it will install. */
|
|
@@ -6492,6 +6601,14 @@ function apply(ctx, config) {
|
|
|
6492
6601
|
}
|
|
6493
6602
|
if (route === "version") return send(res, 200, req.method === "POST" ? await updates.check() : updates.state());
|
|
6494
6603
|
if (route === "update" && req.method === "POST") return send(res, 200, await updates.install());
|
|
6604
|
+
if (route === "access") {
|
|
6605
|
+
if (req.method !== "POST") return send(res, 200, await accessStatus());
|
|
6606
|
+
const b = await readJson(req);
|
|
6607
|
+
const kind = String(b.kind);
|
|
6608
|
+
if (b.action === "settings") return send(res, 200, { opened: openPrivacySettings(kind) });
|
|
6609
|
+
if (kind !== "camera" && kind !== "microphone") return send(res, 400, { error: "camera or microphone" });
|
|
6610
|
+
return send(res, 200, { state: await askAccess(kind) });
|
|
6611
|
+
}
|
|
6495
6612
|
if (route === "devices") {
|
|
6496
6613
|
const devices = await listDevices();
|
|
6497
6614
|
log(`devices: ${devices.filter((d) => d.kind !== "mic").length} picture, ${devices.filter((d) => d.kind === "mic").length} microphone`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vidi",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.3",
|
|
4
4
|
"description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|