@officexapp/vidfarm-devcli 0.21.15 → 0.21.17
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/.agents/skills/editor-capabilities/SKILL.md +3 -3
- package/.agents/skills/{vidfarm-director → vidfarm}/SKILL.md +40 -7
- package/.agents/skills/{vidfarm-director → vidfarm}/references/assets-and-sourcing.md +6 -0
- package/.agents/skills/{vidfarm-director → vidfarm}/references/automation-and-local-dev.md +38 -6
- package/.agents/skills/{vidfarm-director → vidfarm}/references/core-workflows.md +2 -2
- package/.agents/skills/{vidfarm-director → vidfarm}/references/editor-workflows.md +29 -2
- package/.agents/skills/{vidfarm-director → vidfarm}/references/rest-api.md +1 -1
- package/SKILL.director.md +115 -17
- package/SKILL.md +10 -5
- package/demo/dist/app.js +207 -207
- package/dist/src/cli.js +333 -46
- package/dist/src/devcli/cost-mode.js +22 -7
- package/dist/src/devcli/doctor.js +12 -2
- package/dist/src/devcli/greenscreen-local.js +191 -0
- package/dist/src/services/clip-curation/hunt.js +5 -4
- package/dist/src/services/clip-curation/media-select.js +76 -30
- package/package.json +2 -1
- package/public/serve-shells/editor.html +17 -7
- package/public/serve-shells/library-files.html +89 -5
- package/public/serve-shells/library-raws.html +224 -18
- package/public/serve-shells/tools-clipper.html +75 -6
- package/public/serve-shells/tools-image.html +174 -7
- package/public/serve-shells/tools-video.html +104 -6
- /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/find-and-fork-template.md +0 -0
- /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/local-edit-render-approve.md +0 -0
- /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/onboard-a-new-director.md +0 -0
- /package/.agents/skills/{vidfarm-director → vidfarm}/recipes/retheme-template.md +0 -0
- /package/.agents/skills/{vidfarm-director → vidfarm}/references/onboarding.md +0 -0
- /package/.agents/skills/{vidfarm-director → vidfarm}/references/primitives.md +0 -0
|
@@ -129,9 +129,15 @@ export async function runDoctorCommand(argv) {
|
|
|
129
129
|
const ffmpegOk = await hasFfmpeg();
|
|
130
130
|
add("ffmpeg", ffmpegOk ? "ok" : "fail", ffmpegOk ? ffmpegPath : "not found (bundled ffmpeg-static missing and none on PATH) — media commands cannot run");
|
|
131
131
|
add("ffprobe", existsSync(ffprobePath) || spawnSync(ffprobePath, ["-version"], { stdio: "ignore" }).status === 0 ? "ok" : "warn", ffprobePath);
|
|
132
|
-
// 3. hyperframes CLI
|
|
132
|
+
// 3. hyperframes CLI — Vidfarm's own render/animation engine, open-source
|
|
133
|
+
// under a whitelabel (`vidfarm hf` == hyperframes; vidfarm renders ARE
|
|
134
|
+
// hyperframes renders). Powers local engines: render, Kokoro tts, whisper stt,
|
|
135
|
+
// ONNX matting, capture, beats. Recommend a standalone install so they run
|
|
136
|
+
// natively instead of the slow `npx -y hyperframes` fallback.
|
|
133
137
|
const hyperframesCli = resolveHyperframesCli();
|
|
134
|
-
add("hyperframes", hyperframesCli ? "ok" : "warn", hyperframesCli
|
|
138
|
+
add("hyperframes", hyperframesCli ? "ok" : "warn", hyperframesCli
|
|
139
|
+
? `${hyperframesCli} (Vidfarm's whitelabel render/animation engine — reach it via \`vidfarm hf …\`)`
|
|
140
|
+
: "not installed — Vidfarm's render/animation engine (open-source whitelabel). Install for native local render/tts/stt/matting: `npm i -g hyperframes` (else falls back to slow `npx -y hyperframes`)");
|
|
135
141
|
// 4. Chrome for the in-process render (stills / serve local render).
|
|
136
142
|
const chrome = detectChromeForRender();
|
|
137
143
|
add("chrome", chrome.found ? "ok" : "warn", chrome.found ? chrome.detail : `${chrome.detail} — local render/stills will try to download one on first run`);
|
|
@@ -241,6 +247,10 @@ export async function runDoctorCommand(argv) {
|
|
|
241
247
|
if (ffmpegMissing) {
|
|
242
248
|
console.log(`\n${RED}ffmpeg is a hard requirement — install it (or \`npm i ffmpeg-static\`) and re-run.${RESET}`);
|
|
243
249
|
}
|
|
250
|
+
console.log(`\n${DIM}Local Vidfarm runs on a trio — install & use all three together:${RESET}\n` +
|
|
251
|
+
` ${DIM}• ${RESET}vidfarm${DIM} orchestrator (this CLI)${RESET}\n` +
|
|
252
|
+
` ${DIM}• ${RESET}ffmpeg${DIM} media engine (greenscreen, demux, clips, stills) — required${RESET}\n` +
|
|
253
|
+
` ${DIM}• ${RESET}hyperframes${DIM} Vidfarm's open-source render/animation engine (whitelabel; \`vidfarm hf …\`) — \`npm i -g hyperframes\`${RESET}`);
|
|
244
254
|
}
|
|
245
255
|
if (ffmpegMissing)
|
|
246
256
|
process.exitCode = 1;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Bundle-safe LOCAL green-screen / chroma-key for the published devcli.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists: `remove-greenscreen --local` used to route through the
|
|
4
|
+
// in-process Hono backend (see local-backend.ts), which is INTENTIONALLY
|
|
5
|
+
// stripped from the npm tarball — so local chroma-key could never run in the
|
|
6
|
+
// shipped CLI (it threw LocalModeUnavailableError). But keying a flat color is
|
|
7
|
+
// a pure ffmpeg op with zero server dependency, exactly like `remove-background`
|
|
8
|
+
// rides the bundled `hyperframes` CLI. This module reproduces the SAME ffmpeg
|
|
9
|
+
// filter chain the cloud primitive uses (chromaKeyVideoAsset in
|
|
10
|
+
// services/media-processing.ts) so local output matches cloud, and reuses the
|
|
11
|
+
// shared, cached ffmpeg locator from clip-curation (already in the CLI's vetted
|
|
12
|
+
// import closure — no backend module is imported here, keeping the pack guard
|
|
13
|
+
// happy).
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, rmSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { resolveFfmpeg, resolveFfprobe, hasFfmpeg } from "../services/clip-curation/ffmpeg.js";
|
|
18
|
+
// Mirror of GREENSCREEN_PRESETS in primitive-registry.ts (kept in sync by hand;
|
|
19
|
+
// it's a tiny, stable table). Each preset carries the flat key color plus the
|
|
20
|
+
// tolerance/softness that read well for that background.
|
|
21
|
+
export const GREENSCREEN_PRESETS = {
|
|
22
|
+
green: { label: "Green screen", key_color: "#00FF00", tolerance: 0.3, softness: 0.1 },
|
|
23
|
+
"digital-green": { label: "Digital green", key_color: "#00B140", tolerance: 0.28, softness: 0.1 },
|
|
24
|
+
blue: { label: "Blue screen", key_color: "#0047BB", tolerance: 0.3, softness: 0.1 },
|
|
25
|
+
white: { label: "White", key_color: "#FFFFFF", tolerance: 0.18, softness: 0.06 },
|
|
26
|
+
black: { label: "Black", key_color: "#000000", tolerance: 0.14, softness: 0.05 },
|
|
27
|
+
magenta: { label: "Magenta", key_color: "#FF00FF", tolerance: 0.3, softness: 0.1 }
|
|
28
|
+
};
|
|
29
|
+
/** ffmpeg wants a `0xRRGGBB` literal or a named color; anything else → black.
|
|
30
|
+
* Same normalization the cloud path uses (media-processing.ts). */
|
|
31
|
+
export function normalizeFfmpegColor(value) {
|
|
32
|
+
const trimmed = value.trim();
|
|
33
|
+
const hex = /^#?([0-9a-f]{6})$/i.exec(trimmed);
|
|
34
|
+
if (hex)
|
|
35
|
+
return `0x${hex[1]}`;
|
|
36
|
+
return /^[a-z]+$/i.test(trimmed) ? trimmed : "black";
|
|
37
|
+
}
|
|
38
|
+
/** ffmpeg's `despill` filter only knows green/blue. Return the matching type
|
|
39
|
+
* when the key color is clearly green- or blue-dominant, else null (white/
|
|
40
|
+
* black/magenta flat fills have no hue fringe to trim). Cloud-parity. */
|
|
41
|
+
export function despillTypeForColor(value) {
|
|
42
|
+
const named = { green: "green", lime: "green", blue: "blue" };
|
|
43
|
+
const lower = value.trim().toLowerCase();
|
|
44
|
+
if (named[lower])
|
|
45
|
+
return named[lower];
|
|
46
|
+
const hex = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(lower);
|
|
47
|
+
if (!hex)
|
|
48
|
+
return null;
|
|
49
|
+
const r = parseInt(hex[1], 16);
|
|
50
|
+
const g = parseInt(hex[2], 16);
|
|
51
|
+
const b = parseInt(hex[3], 16);
|
|
52
|
+
const max = Math.max(r, g, b);
|
|
53
|
+
const min = Math.min(r, g, b);
|
|
54
|
+
if (max - min < 40)
|
|
55
|
+
return null;
|
|
56
|
+
if (g === max)
|
|
57
|
+
return "green";
|
|
58
|
+
if (b === max)
|
|
59
|
+
return "blue";
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** True when a usable ffmpeg is available (bundled ffmpeg-static or on PATH). */
|
|
63
|
+
export async function localGreenscreenAvailable() {
|
|
64
|
+
return hasFfmpeg();
|
|
65
|
+
}
|
|
66
|
+
function runFfmpeg(bin, args) {
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
69
|
+
let stderr = "";
|
|
70
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
71
|
+
child.on("error", reject);
|
|
72
|
+
child.on("close", (code) => resolve({ code: code ?? 0, stderr }));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Report a video stream's pixel format (e.g. "yuva420p" carries alpha). */
|
|
76
|
+
async function probePixFmt(filePath) {
|
|
77
|
+
const ffprobe = await resolveFfprobe();
|
|
78
|
+
const args = ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=pix_fmt", "-of", "csv=p=0", filePath];
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const child = spawn(ffprobe, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
81
|
+
let out = "";
|
|
82
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
83
|
+
child.on("error", () => resolve(""));
|
|
84
|
+
child.on("close", () => resolve(out.trim()));
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Key out a flat, solid background LOCALLY with ffmpeg — free, offline, no
|
|
89
|
+
* wallet, no backend. Uses the SAME chromakey/despill filter chain as the cloud
|
|
90
|
+
* primitive so results match.
|
|
91
|
+
*
|
|
92
|
+
* IMAGE → transparent PNG/WebP.
|
|
93
|
+
* VIDEO → browser-ready VP9-alpha WebM when the local ffmpeg's libvpx supports
|
|
94
|
+
* alpha; otherwise (many ffmpeg-static builds can't encode WebM/VP9 alpha at
|
|
95
|
+
* all) it transparently falls back to a ProRes 4444 .mov alpha master so the
|
|
96
|
+
* caller always gets a genuinely-transparent file rather than a silently
|
|
97
|
+
* opaque one. The `webmAlpha` flag tells the caller which happened.
|
|
98
|
+
*/
|
|
99
|
+
export async function removeGreenscreenLocal(input) {
|
|
100
|
+
if (!existsSync(input.sourcePath))
|
|
101
|
+
throw new Error(`No such source file: ${input.sourcePath}`);
|
|
102
|
+
const keyColorRaw = (input.keyColor ?? "#00FF00").trim() || "#00FF00";
|
|
103
|
+
const ffmpegColor = normalizeFfmpegColor(keyColorRaw);
|
|
104
|
+
// chromakey `similarity` (color-match radius) and `blend` (soft edge band) are
|
|
105
|
+
// both 0..1; clamp to the same sane bounds the cloud path uses.
|
|
106
|
+
const similarity = Math.min(0.9, Math.max(0.01, input.tolerance ?? 0.3));
|
|
107
|
+
const blend = Math.min(0.9, Math.max(0, input.softness ?? 0.1));
|
|
108
|
+
const despill = input.despill ?? true;
|
|
109
|
+
const despillType = despillTypeForColor(keyColorRaw);
|
|
110
|
+
const chroma = `chromakey=${ffmpegColor}:${similarity.toFixed(3)}:${blend.toFixed(3)}`;
|
|
111
|
+
const despillClause = despill && despillType ? `,despill=type=${despillType}` : "";
|
|
112
|
+
const ffmpeg = await resolveFfmpeg();
|
|
113
|
+
if (input.mediaType === "image") {
|
|
114
|
+
// Single-frame RGBA still. rgba carries the alpha into PNG/WebP output.
|
|
115
|
+
// WebP needs the libwebp encoder explicitly — the generic muxer writes an
|
|
116
|
+
// invalid (animated-stub) file for a single frame.
|
|
117
|
+
const isWebp = /\.webp$/i.test(input.outputPath);
|
|
118
|
+
const args = [
|
|
119
|
+
"-hide_banner", "-y",
|
|
120
|
+
"-i", input.sourcePath,
|
|
121
|
+
"-frames:v", "1",
|
|
122
|
+
"-vf", `${chroma}${despillClause},format=rgba`,
|
|
123
|
+
...(isWebp ? ["-c:v", "libwebp", "-lossless", "1", "-pix_fmt", "rgba"] : []),
|
|
124
|
+
input.outputPath
|
|
125
|
+
];
|
|
126
|
+
const { code, stderr } = await runFfmpeg(ffmpeg, args);
|
|
127
|
+
if (code !== 0 || !existsSync(input.outputPath)) {
|
|
128
|
+
throw new Error(`Local greenscreen key failed (ffmpeg exit ${code})${tail(stderr)}.`);
|
|
129
|
+
}
|
|
130
|
+
return { outputPath: input.outputPath, format: isWebp ? "webp" : "png", alpha: true, webmAlpha: false };
|
|
131
|
+
}
|
|
132
|
+
// VIDEO — try browser-ready VP9 alpha WebM first (mirrors chromaKeyVideoAsset).
|
|
133
|
+
const webmPath = input.outputPath.replace(/\.[^.]+$/, "") + ".webm";
|
|
134
|
+
const webmArgs = [
|
|
135
|
+
"-hide_banner", "-y",
|
|
136
|
+
"-i", input.sourcePath,
|
|
137
|
+
"-map", "0:v:0",
|
|
138
|
+
"-map", "0:a:0?",
|
|
139
|
+
"-vf", `${chroma}${despillClause},format=yuva420p`,
|
|
140
|
+
"-c:v", "libvpx-vp9",
|
|
141
|
+
"-pix_fmt", "yuva420p",
|
|
142
|
+
"-auto-alt-ref", "0",
|
|
143
|
+
"-b:v", "0",
|
|
144
|
+
"-crf", "30",
|
|
145
|
+
"-row-mt", "1",
|
|
146
|
+
"-deadline", "good",
|
|
147
|
+
"-cpu-used", "4",
|
|
148
|
+
"-c:a", "libopus",
|
|
149
|
+
"-b:a", "128k",
|
|
150
|
+
webmPath
|
|
151
|
+
];
|
|
152
|
+
const webm = await runFfmpeg(ffmpeg, webmArgs);
|
|
153
|
+
if (webm.code === 0 && existsSync(webmPath)) {
|
|
154
|
+
// Verify the alpha actually survived — many libvpx builds silently drop it.
|
|
155
|
+
const pixFmt = await probePixFmt(webmPath);
|
|
156
|
+
if (/^yuva/i.test(pixFmt)) {
|
|
157
|
+
return { outputPath: webmPath, format: "webm", alpha: true, webmAlpha: true };
|
|
158
|
+
}
|
|
159
|
+
rmSync(webmPath, { force: true }); // opaque WebM is useless — discard it
|
|
160
|
+
}
|
|
161
|
+
// Fallback: ProRes 4444 .mov guarantees a real alpha channel everywhere.
|
|
162
|
+
const movPath = input.outputPath.replace(/\.[^.]+$/, "") + ".mov";
|
|
163
|
+
const movArgs = [
|
|
164
|
+
"-hide_banner", "-y",
|
|
165
|
+
"-i", input.sourcePath,
|
|
166
|
+
"-map", "0:v:0",
|
|
167
|
+
"-map", "0:a:0?",
|
|
168
|
+
"-vf", `${chroma}${despillClause},format=yuva444p10le`,
|
|
169
|
+
"-c:v", "prores_ks",
|
|
170
|
+
"-profile:v", "4444",
|
|
171
|
+
"-pix_fmt", "yuva444p10le",
|
|
172
|
+
"-c:a", "pcm_s16le",
|
|
173
|
+
movPath
|
|
174
|
+
];
|
|
175
|
+
const mov = await runFfmpeg(ffmpeg, movArgs);
|
|
176
|
+
if (mov.code !== 0 || !existsSync(movPath)) {
|
|
177
|
+
throw new Error(`Local greenscreen key failed (ffmpeg exit ${mov.code})${tail(mov.stderr)}.`);
|
|
178
|
+
}
|
|
179
|
+
return { outputPath: movPath, format: "mov", alpha: true, webmAlpha: false };
|
|
180
|
+
}
|
|
181
|
+
function tail(stderr) {
|
|
182
|
+
const t = stderr.trim().split("\n").slice(-3).join("\n");
|
|
183
|
+
return t ? `:\n${t}` : "";
|
|
184
|
+
}
|
|
185
|
+
/** Default output path next to a source, matching remove-background's naming. */
|
|
186
|
+
export function defaultGreenscreenOutPath(sourcePath, mediaType, outputFormat) {
|
|
187
|
+
const stem = path.basename(sourcePath).replace(/\.[^.]+$/, "");
|
|
188
|
+
const ext = mediaType === "video" ? "webm" : outputFormat === "webp" ? "webp" : "png";
|
|
189
|
+
return path.resolve(process.cwd(), `${stem}.transparent.${ext}`);
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=greenscreen-local.js.map
|
|
@@ -20,15 +20,16 @@ export const DEFAULT_CLIPS_PER_10_MIN = 10;
|
|
|
20
20
|
export const DEFAULT_CLIP_ASPECT = "9:16";
|
|
21
21
|
/**
|
|
22
22
|
* Fill the unspecified-prompt defaults on a normalized hunt spec: a 5–15s
|
|
23
|
-
* duration band
|
|
24
|
-
*
|
|
23
|
+
* duration band and a vertical 9:16 crop. avoid-text is deliberately NOT
|
|
24
|
+
* defaulted on — most social sources (TikTok/Shorts/Reels) are captioned
|
|
25
|
+
* wall-to-wall, so an implicit avoid_text:true rejects EVERY scene and yields a
|
|
26
|
+
* silent 0-clip hunt. avoid_text stays opt-in (set true only when the user asks
|
|
27
|
+
* to avoid on-screen text); an explicit choice either way is preserved.
|
|
25
28
|
*/
|
|
26
29
|
export function applyHuntSpecDefaults(spec) {
|
|
27
30
|
const out = { ...spec };
|
|
28
31
|
if (!out.duration_band)
|
|
29
32
|
out.duration_band = { ...DEFAULT_CLIP_DURATION_BAND };
|
|
30
|
-
if (out.avoid_text === undefined)
|
|
31
|
-
out.avoid_text = true;
|
|
32
33
|
if (!out.aspect)
|
|
33
34
|
out.aspect = DEFAULT_CLIP_ASPECT;
|
|
34
35
|
return out;
|
|
@@ -103,50 +103,96 @@ export function pickBestVideoMedia(medias) {
|
|
|
103
103
|
// bare programmatic fetches, and the top-res server-side "merge" rendition
|
|
104
104
|
// transiently 403s while the merge runs or when rate-limited.
|
|
105
105
|
const MEDIA_DOWNLOAD_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
|
106
|
-
|
|
106
|
+
// Retryable within a round only for genuinely transient failures. A bot-challenge
|
|
107
|
+
// (Cloudflare "Just a moment…") is NOT retryable in-round — the same signed URL
|
|
108
|
+
// stays gated; only a fresh re-lookup (new token/proxy edge) can clear it.
|
|
109
|
+
const RETRYABLE_DOWNLOAD_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
107
110
|
function delay(ms) {
|
|
108
111
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
109
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* A response that is HTML (or an explicit anti-bot challenge) is NOT the video —
|
|
115
|
+
* the snapvideo.co CDN hosts sit behind Cloudflare, which answers server-side
|
|
116
|
+
* fetches with a `text/html` "Just a moment…" interstitial (usually 403, but it
|
|
117
|
+
* can also come back 200). Streaming that to disk would save a bogus "video", so
|
|
118
|
+
* we treat any HTML body as a download failure regardless of status code.
|
|
119
|
+
*/
|
|
120
|
+
function looksLikeBotChallenge(response) {
|
|
121
|
+
const ct = (response.headers.get("content-type") || "").toLowerCase();
|
|
122
|
+
if (ct.includes("text/html") || ct.includes("application/xhtml"))
|
|
123
|
+
return true;
|
|
124
|
+
// Cloudflare stamps its edge on the challenge response.
|
|
125
|
+
const cfMitigated = (response.headers.get("cf-mitigated") || "").toLowerCase();
|
|
126
|
+
return cfMitigated.includes("challenge");
|
|
127
|
+
}
|
|
110
128
|
/**
|
|
111
129
|
* Download the first playable rendition that actually responds, walking the
|
|
112
130
|
* ranked candidates highest-res first. The RapidAPI resolver returns SEVERAL
|
|
113
131
|
* copies it hosts itself (e.g. a progressive 360p plus server-side-merged
|
|
114
|
-
* 720p/1080p)
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
132
|
+
* 720p/1080p). The merge renditions sit behind a Cloudflare bot-challenge that a
|
|
133
|
+
* Lambda fetch can't clear, and even the progressive proxy gets *intermittently*
|
|
134
|
+
* challenged — so a single lookup can momentarily return an all-gated set. We
|
|
135
|
+
* therefore (a) reject HTML/challenge bodies instead of saving them as video,
|
|
136
|
+
* (b) fall back across renditions within a lookup, and (c) when `reResolve` is
|
|
137
|
+
* supplied, re-run the resolver for a FRESH signed set (new token + proxy edge,
|
|
138
|
+
* which is what actually clears a transient full-gate) and walk it again.
|
|
139
|
+
* Returns the live Response (caller streams `response.body` to disk/S3) plus the
|
|
140
|
+
* winning candidate. Throws only if every rendition across every round is
|
|
141
|
+
* unreachable, with each failure in the message.
|
|
119
142
|
*/
|
|
120
|
-
export async function fetchFirstDownloadableMedia(medias) {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
throw new Error("Video download lookup returned no playable MP4 media URL.");
|
|
124
|
-
}
|
|
143
|
+
export async function fetchFirstDownloadableMedia(medias, opts = {}) {
|
|
144
|
+
const { reResolve } = opts;
|
|
145
|
+
const maxRounds = reResolve ? 3 : 1;
|
|
125
146
|
const failures = [];
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await delay(750);
|
|
147
|
+
let current = medias;
|
|
148
|
+
let lastRankedCount = 0;
|
|
149
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
150
|
+
if (round > 0) {
|
|
151
|
+
// Back off, then mint a fresh signed set — the only thing that recovers a
|
|
152
|
+
// transient Cloudflare gate on the CDN hosts.
|
|
153
|
+
await delay(1000 * round);
|
|
134
154
|
try {
|
|
135
|
-
|
|
136
|
-
if (response.ok && response.body) {
|
|
137
|
-
return { response, media };
|
|
138
|
-
}
|
|
139
|
-
failures.push(`${label}:HTTP ${response.status}`);
|
|
140
|
-
await response.body?.cancel?.().catch(() => { });
|
|
141
|
-
// A non-transient status won't fix itself on retry — move to the next copy.
|
|
142
|
-
if (!RETRYABLE_DOWNLOAD_STATUS.has(response.status))
|
|
143
|
-
break;
|
|
155
|
+
current = await reResolve();
|
|
144
156
|
}
|
|
145
157
|
catch (error) {
|
|
146
|
-
failures.push(
|
|
158
|
+
failures.push(`re-lookup:${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const ranked = rankPlayableVideoMedias(current);
|
|
163
|
+
lastRankedCount = ranked.length;
|
|
164
|
+
if (!ranked.length) {
|
|
165
|
+
failures.push(round === 0 ? "no playable media in lookup" : `round ${round + 1}: no playable media`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const media of ranked) {
|
|
169
|
+
const url = media.url;
|
|
170
|
+
if (!url)
|
|
171
|
+
continue;
|
|
172
|
+
const label = media.quality ?? media.label ?? "?";
|
|
173
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
174
|
+
if (attempt > 0)
|
|
175
|
+
await delay(750);
|
|
176
|
+
try {
|
|
177
|
+
const response = await fetch(url, { headers: { "user-agent": MEDIA_DOWNLOAD_UA, accept: "*/*" } });
|
|
178
|
+
const challenged = looksLikeBotChallenge(response);
|
|
179
|
+
if (response.ok && response.body && !challenged) {
|
|
180
|
+
return { response, media };
|
|
181
|
+
}
|
|
182
|
+
failures.push(`${label}:HTTP ${response.status}${challenged ? " bot-challenge" : ""}`);
|
|
183
|
+
await response.body?.cancel?.().catch(() => { });
|
|
184
|
+
// A bot-challenge or any non-transient status won't clear on an
|
|
185
|
+
// immediate retry of the same URL — move to the next rendition (and,
|
|
186
|
+
// ultimately, the next re-lookup round).
|
|
187
|
+
if (challenged || !RETRYABLE_DOWNLOAD_STATUS.has(response.status))
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
failures.push(`${label}:${error instanceof Error ? error.message : String(error)}`);
|
|
192
|
+
}
|
|
147
193
|
}
|
|
148
194
|
}
|
|
149
195
|
}
|
|
150
|
-
throw new Error(`Source video download failed; all ${
|
|
196
|
+
throw new Error(`Source video download failed; all ${lastRankedCount} rendition(s) unavailable (${failures.join("; ")}).`);
|
|
151
197
|
}
|
|
152
198
|
//# sourceMappingURL=media-select.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@officexapp/vidfarm-devcli",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.17",
|
|
4
4
|
"description": "Local bridge for the Vidfarm Trackpad Editor. `vidfarm serve <template_id>` boots the FULL editor on localhost (disk-backed records/storage, free in-process render); edit composition.html on disk (Claude Code, Codex, etc.) and the browser live-morphs it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"dist/src/devcli/composition-edit.js",
|
|
18
18
|
"dist/src/devcli/cost-mode.js",
|
|
19
19
|
"dist/src/devcli/doctor.js",
|
|
20
|
+
"dist/src/devcli/greenscreen-local.js",
|
|
20
21
|
"dist/src/devcli/hyperframes-cli.js",
|
|
21
22
|
"dist/src/devcli/local-backend.js",
|
|
22
23
|
"dist/src/devcli/local-frontend-server.js",
|
|
@@ -367,7 +367,10 @@
|
|
|
367
367
|
/* chat column */
|
|
368
368
|
.rk-aichat-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
369
369
|
.rk-aichat-head{display:flex;align-items:center;gap:8px;padding:14px 14px;border-bottom:1px solid var(--rk-border);flex:none}
|
|
370
|
-
.rk-aichat-head-main{flex:1;min-width:0;display:
|
|
370
|
+
.rk-aichat-head-main{flex:1;min-width:0;display:flex;align-items:center;gap:10px}
|
|
371
|
+
.rk-aichat-head-titles{display:grid;gap:1px;min-width:0}
|
|
372
|
+
.rk-aichat-watch{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--rk-text-muted);font-size:12px;font-weight:400;font-family:inherit;cursor:pointer;padding:3px 8px;border-radius:8px;white-space:nowrap;transition:color .15s,background .15s}
|
|
373
|
+
.rk-aichat-watch:hover{color:var(--rk-ink);background:var(--rk-n-100)}
|
|
371
374
|
.rk-aichat-title{font-family:var(--rk-font-display);font-weight:800;font-size:15px;color:var(--rk-ink);letter-spacing:-.01em}
|
|
372
375
|
.rk-aichat-sub{font-size:11.5px;color:var(--rk-text-muted)}
|
|
373
376
|
.rk-aichat-back{flex:1;min-width:0;display:inline-flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;
|
|
@@ -1125,6 +1128,17 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1125
1128
|
.rk-vf-btn:hover:not(:disabled){background:#262b1a;border-color:var(--rk-border-strong)}
|
|
1126
1129
|
.rk-vf-btn:disabled{opacity:.45;cursor:default}
|
|
1127
1130
|
.rk-vf-status{font-size:11.5px;color:var(--rk-text-muted);margin:0}
|
|
1131
|
+
.rk-vf-renders{display:grid;gap:5px;margin-top:2px}
|
|
1132
|
+
.rk-vf-renders-head{display:flex;align-items:center;justify-content:space-between}
|
|
1133
|
+
.rk-vf-renders-refresh{background:none;border:none;color:var(--rk-text-muted);cursor:pointer;
|
|
1134
|
+
font-size:13px;line-height:1;padding:2px 4px;border-radius:6px}
|
|
1135
|
+
.rk-vf-renders-refresh:hover{color:var(--rk-ink);background:#262b1a}
|
|
1136
|
+
.rk-vf-render-row{display:flex;align-items:center;justify-content:space-between;gap:8px;
|
|
1137
|
+
padding:7px 10px;border-radius:var(--rk-r-lg);border:1px solid var(--rk-border);background:#171b10;
|
|
1138
|
+
color:var(--rk-ink);font-size:12px;text-decoration:none;
|
|
1139
|
+
transition:background var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
|
|
1140
|
+
.rk-vf-render-row:hover{background:#222717;border-color:var(--rk-border-strong)}
|
|
1141
|
+
.rk-vf-render-label{font-weight:600}
|
|
1128
1142
|
|
|
1129
1143
|
/* ── Rebrand: the sealed upstream <StudioApp/> header renders a "HeyGen ·
|
|
1130
1144
|
HyperFrames" logo SVG (aria-label="Hyperframes"). The actual swap to a VidFarm
|
|
@@ -1189,10 +1203,6 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1189
1203
|
</div>
|
|
1190
1204
|
<div class="rk-aichat-main">
|
|
1191
1205
|
<header class="rk-aichat-head">
|
|
1192
|
-
<a class="rk-aichat-back" id="rkAichatBack" href="/library" title="Back to your library">
|
|
1193
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
|
1194
|
-
<span>Back to Library</span>
|
|
1195
|
-
</a>
|
|
1196
1206
|
<div class="rk-aichat-skill"><div class="vf-skill-install vf-skill-install-nav" data-skill-install>
|
|
1197
1207
|
<button
|
|
1198
1208
|
class="vf-skill-install-main"
|
|
@@ -1220,12 +1230,12 @@ html,body{margin:0;background:#050604;color:#fffbe6}
|
|
|
1220
1230
|
<div class="vf-skill-install-popover" data-skill-menu hidden>
|
|
1221
1231
|
<div class="vf-skill-install-popover-kicker">Install Command</div>
|
|
1222
1232
|
<div class="vf-skill-install-command">
|
|
1223
|
-
<input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm
|
|
1233
|
+
<input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm" data-skill-command-input aria-label="Skill install command">
|
|
1224
1234
|
<button class="vf-skill-install-copy" type="button" data-skill-command-copy aria-label="Copy install command" title="Copy install command">
|
|
1225
1235
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
1226
1236
|
</button>
|
|
1227
1237
|
</div>
|
|
1228
|
-
<a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm
|
|
1238
|
+
<a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm" target="_blank" rel="noopener noreferrer">
|
|
1229
1239
|
<span class="vf-skill-install-link-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6"></path><path d="M10 14 20 4"></path><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5"></path></svg></span>
|
|
1230
1240
|
<span>View GitHub</span>
|
|
1231
1241
|
</a>
|
|
@@ -382,7 +382,10 @@
|
|
|
382
382
|
/* chat column */
|
|
383
383
|
.rk-aichat-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
384
384
|
.rk-aichat-head{display:flex;align-items:center;gap:8px;padding:14px 14px;border-bottom:1px solid var(--rk-border);flex:none}
|
|
385
|
-
.rk-aichat-head-main{flex:1;min-width:0;display:
|
|
385
|
+
.rk-aichat-head-main{flex:1;min-width:0;display:flex;align-items:center;gap:10px}
|
|
386
|
+
.rk-aichat-head-titles{display:grid;gap:1px;min-width:0}
|
|
387
|
+
.rk-aichat-watch{display:inline-flex;align-items:center;gap:5px;border:0;background:transparent;color:var(--rk-text-muted);font-size:12px;font-weight:400;font-family:inherit;cursor:pointer;padding:3px 8px;border-radius:8px;white-space:nowrap;transition:color .15s,background .15s}
|
|
388
|
+
.rk-aichat-watch:hover{color:var(--rk-ink);background:var(--rk-n-100)}
|
|
386
389
|
.rk-aichat-title{font-family:var(--rk-font-display);font-weight:800;font-size:15px;color:var(--rk-ink);letter-spacing:-.01em}
|
|
387
390
|
.rk-aichat-sub{font-size:11.5px;color:var(--rk-text-muted)}
|
|
388
391
|
.rk-aichat-back{flex:1;min-width:0;display:inline-flex;align-items:center;gap:8px;padding:7px 12px 7px 9px;
|
|
@@ -1367,6 +1370,25 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1367
1370
|
.rk-clips-dialog-note[data-tone="error"]{background:#fdecea;color:#8d3b2f}
|
|
1368
1371
|
.rk-clips-dialog-note[data-tone="success"]{background:#e8f7ee;color:#1f7a44}
|
|
1369
1372
|
.rk-clips-dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:4px}
|
|
1373
|
+
/* Import Raws: file-upload dropzone (mirrors discover) + folder picker trigger. */
|
|
1374
|
+
.rk-add-optional{font-weight:500;color:var(--rk-text-faint)}
|
|
1375
|
+
.rk-add-or{display:flex;align-items:center;gap:12px;color:var(--rk-text-faint);font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
|
|
1376
|
+
.rk-add-or::before,.rk-add-or::after{content:"";height:1px;flex:1;background:var(--rk-border)}
|
|
1377
|
+
.rk-add-file{display:flex;align-items:center;gap:12px;border:1px dashed var(--rk-border-strong);border-radius:var(--rk-r-lg);padding:13px 14px;cursor:pointer;color:var(--rk-n-700);transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
|
|
1378
|
+
.rk-add-file:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
|
|
1379
|
+
.rk-add-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
|
|
1380
|
+
.rk-add-file-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
|
|
1381
|
+
.rk-add-file-text{font-size:13.5px;font-weight:600;display:grid;gap:2px;min-width:0}
|
|
1382
|
+
.rk-add-file-text .rk-add-optional{font-size:11.5px;font-weight:500}
|
|
1383
|
+
.rk-add-file.is-picked{border-style:solid;border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
|
|
1384
|
+
.rk-folderpick{display:flex;align-items:stretch;gap:8px}
|
|
1385
|
+
.rk-folderpick-btn{flex:1;min-width:0;display:flex;align-items:center;gap:9px;text-align:left;cursor:pointer;color:var(--rk-text-faint);font-weight:500}
|
|
1386
|
+
.rk-folderpick-btn:hover{border-color:var(--rk-gold-600)}
|
|
1387
|
+
.rk-folderpick-btn.is-picked{color:var(--rk-ink);font-weight:600}
|
|
1388
|
+
.rk-folderpick-ic{flex:none;color:var(--rk-gold-700);display:grid;place-items:center}
|
|
1389
|
+
.rk-folderpick-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
1390
|
+
.rk-folderpick-clear{flex:none;width:38px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-50);color:var(--rk-n-700);cursor:pointer;font-size:13px;line-height:1;transition:border-color var(--rk-dur) var(--rk-ease),background var(--rk-dur) var(--rk-ease)}
|
|
1391
|
+
.rk-folderpick-clear:hover{border-color:var(--rk-gold-600);background:var(--rk-gold-tint)}
|
|
1370
1392
|
/* ── files tab: full-page mount of the shared directory-explorer component ──
|
|
1371
1393
|
The component (.rk-dir + .rk-aichat-* rows) is tuned for the narrow chat
|
|
1372
1394
|
drawer; on a full page we wrap it in a card and scale the search, crumbs and
|
|
@@ -1437,12 +1459,12 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1437
1459
|
<div class="vf-skill-install-popover" data-skill-menu hidden>
|
|
1438
1460
|
<div class="vf-skill-install-popover-kicker">Install Command</div>
|
|
1439
1461
|
<div class="vf-skill-install-command">
|
|
1440
|
-
<input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm
|
|
1462
|
+
<input class="vf-skill-install-input" type="text" readonly value="npx skills add https://github.com/officexapp/vidfarm" data-skill-command-input aria-label="Skill install command">
|
|
1441
1463
|
<button class="vf-skill-install-copy" type="button" data-skill-command-copy aria-label="Copy install command" title="Copy install command">
|
|
1442
1464
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
|
1443
1465
|
</button>
|
|
1444
1466
|
</div>
|
|
1445
|
-
<a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm
|
|
1467
|
+
<a class="vf-skill-install-link" href="https://github.com/officexapp/vidfarm" target="_blank" rel="noopener noreferrer">
|
|
1446
1468
|
<span class="vf-skill-install-link-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6"></path><path d="M10 14 20 4"></path><path d="M20 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5"></path></svg></span>
|
|
1447
1469
|
<span>View GitHub</span>
|
|
1448
1470
|
</a>
|
|
@@ -1537,8 +1559,13 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
1537
1559
|
<header class="rk-aichat-head">
|
|
1538
1560
|
<span class="rk-brand-mark">V</span>
|
|
1539
1561
|
<div class="rk-aichat-head-main">
|
|
1540
|
-
<div class="rk-aichat-
|
|
1541
|
-
|
|
1562
|
+
<div class="rk-aichat-head-titles">
|
|
1563
|
+
<div class="rk-aichat-title">VidFarm AI</div>
|
|
1564
|
+
<div class="rk-aichat-sub">on your own AI keys</div>
|
|
1565
|
+
</div>
|
|
1566
|
+
<button class="rk-aichat-watch" id="rkWatchPoppanel" type="button" title="Watch Tutorial" aria-label="Watch Tutorial">
|
|
1567
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>
|
|
1568
|
+
</button>
|
|
1542
1569
|
</div>
|
|
1543
1570
|
<button class="rk-aichat-tool" id="rkHistoryToggle" type="button" title="Chat history" aria-label="Toggle chat history" aria-pressed="false">
|
|
1544
1571
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 2"/></svg>
|
|
@@ -3507,6 +3534,63 @@ button.rk-clips-thumb{appearance:none;-webkit-appearance:none;border:0;margin:0;
|
|
|
3507
3534
|
document.querySelectorAll("[data-skill-menu]").forEach((panel) => { panel.hidden = true; });
|
|
3508
3535
|
});
|
|
3509
3536
|
})();</script>
|
|
3537
|
+
<script>window.__vidfarmTutorials=[["/discover/templates/categories","/assets/blog/tutorial-videos/page-categories.mp4"],["/discover/templates/swipe","/assets/blog/tutorial-videos/page-swipe.mp4"],["/discover","/assets/blog/tutorial-videos/page-discover.mp4"],["/tools/image","/assets/blog/tutorial-videos/tools-image.mp4"],["/tools/video","/assets/blog/tutorial-videos/tools-video.mp4"],["/tools/clipper","/assets/blog/tutorial-videos/tools-clipper.mp4"],["/chat","/assets/blog/tutorial-videos/page-chat.mp4"],["/calendar","/assets/blog/tutorial-videos/page-calendar.mp4"],["/settings/profile","/assets/blog/tutorial-videos/page-profile.mp4"],["/settings/wallet","/assets/blog/tutorial-videos/page-wallet.mp4"],["/settings/channels","/assets/blog/tutorial-videos/page-channels.mp4"],["/settings/developer","/assets/blog/tutorial-videos/page-developer.mp4"],["/library","/assets/blog/tutorial-videos/page-library.mp4"]];window.__vfPoppanelVideo="/assets/blog/tutorial-videos/page-poppanel.mp4";</script>
|
|
3538
|
+
<script>(() => {
|
|
3539
|
+
try {
|
|
3540
|
+
function findTitle() {
|
|
3541
|
+
var hs = document.querySelectorAll('.rk-content h1');
|
|
3542
|
+
for (var i = 0; i < hs.length; i++) { var t = (hs[i].textContent || '').trim(); if (t && t.toLowerCase() !== 'vidfarm') return hs[i]; }
|
|
3543
|
+
return null;
|
|
3544
|
+
}
|
|
3545
|
+
function openModal(url) {
|
|
3546
|
+
if (document.getElementById('vf-tut-modal')) return;
|
|
3547
|
+
var ov = document.createElement('div');
|
|
3548
|
+
ov.id = 'vf-tut-modal';
|
|
3549
|
+
ov.style.cssText = 'position:fixed;inset:0;z-index:2147483000;background:rgba(0,0,0,.72);display:flex;align-items:center;justify-content:center;padding:24px;';
|
|
3550
|
+
var box = document.createElement('div');
|
|
3551
|
+
box.style.cssText = 'position:relative;width:min(900px,92vw);';
|
|
3552
|
+
var vid = document.createElement('video');
|
|
3553
|
+
vid.src = url; vid.controls = true; vid.autoplay = true; vid.playsInline = true;
|
|
3554
|
+
vid.style.cssText = 'width:100%;max-height:86vh;border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,.5);background:#000;display:block;';
|
|
3555
|
+
var x = document.createElement('button');
|
|
3556
|
+
x.type = 'button'; x.setAttribute('aria-label', 'Close'); x.innerHTML = '×';
|
|
3557
|
+
x.style.cssText = 'position:absolute;top:-16px;right:-16px;width:40px;height:40px;border-radius:50%;border:0;background:#fff;color:#111;font-size:26px;line-height:1;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;';
|
|
3558
|
+
function close() { try { vid.pause(); } catch (e) {} ov.remove(); document.removeEventListener('keydown', onKey); }
|
|
3559
|
+
function onKey(e) { if (e.key === 'Escape') close(); }
|
|
3560
|
+
x.onclick = close;
|
|
3561
|
+
ov.onclick = function (e) { if (e.target === ov) close(); };
|
|
3562
|
+
document.addEventListener('keydown', onKey);
|
|
3563
|
+
box.appendChild(vid); box.appendChild(x); ov.appendChild(box); document.body.appendChild(ov);
|
|
3564
|
+
}
|
|
3565
|
+
window.__vfOpenTutorial = openModal;
|
|
3566
|
+
// Pop-panel (agent dock) tutorial button — present on every full-chrome page,
|
|
3567
|
+
// next to the "VidFarm AI" title. Retry briefly in case the dock mounts late.
|
|
3568
|
+
var pt = 0;
|
|
3569
|
+
(function wirePop() {
|
|
3570
|
+
var pv = document.getElementById('rkWatchPoppanel');
|
|
3571
|
+
if (pv && window.__vfPoppanelVideo) { pv.onclick = function () { openModal(window.__vfPoppanelVideo); }; return; }
|
|
3572
|
+
if (pt++ < 20) setTimeout(wirePop, 300);
|
|
3573
|
+
})();
|
|
3574
|
+
// Page-title tutorial button — only when this route has a matching video.
|
|
3575
|
+
var routes = window.__vidfarmTutorials || [];
|
|
3576
|
+
var path = location.pathname, hit = null;
|
|
3577
|
+
for (var i = 0; i < routes.length; i++) { if (path === routes[i][0] || path.indexOf(routes[i][0]) === 0) { hit = routes[i][1]; break; } }
|
|
3578
|
+
function inject() {
|
|
3579
|
+
if (document.getElementById('vf-watch-tut')) return true;
|
|
3580
|
+
var h1 = findTitle(); if (!h1) return false;
|
|
3581
|
+
var btn = document.createElement('button');
|
|
3582
|
+
btn.id = 'vf-watch-tut'; btn.type = 'button'; btn.setAttribute('aria-label', 'Watch Tutorial');
|
|
3583
|
+
btn.style.cssText = 'display:inline-flex;align-items:center;gap:6px;margin-left:16px;padding:4px 10px;border:0;background:transparent;color:#9aa0a6;font-size:13px;font-weight:400;font-family:inherit;letter-spacing:0;cursor:pointer;vertical-align:middle;border-radius:8px;transition:color .15s,background .15s;';
|
|
3584
|
+
btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M10 8.5l6 3.5-6 3.5z" fill="currentColor" stroke="none"></path></svg><span>Watch Tutorial</span>';
|
|
3585
|
+
btn.onmouseenter = function () { btn.style.color = '#3c4043'; btn.style.background = 'rgba(0,0,0,.05)'; };
|
|
3586
|
+
btn.onmouseleave = function () { btn.style.color = '#9aa0a6'; btn.style.background = 'transparent'; };
|
|
3587
|
+
btn.onclick = function () { openModal(hit); };
|
|
3588
|
+
h1.appendChild(btn);
|
|
3589
|
+
return true;
|
|
3590
|
+
}
|
|
3591
|
+
if (hit) { var tries = 0; (function loop() { if (inject()) return; if (tries++ > 40) return; setTimeout(loop, 250); })(); }
|
|
3592
|
+
} catch (e) {}
|
|
3593
|
+
})();</script>
|
|
3510
3594
|
<script type="module" src="/assets/file-directory-app.js"></script>
|
|
3511
3595
|
</body>
|
|
3512
3596
|
</html>
|