@mevdragon/vidfarm-devcli 0.16.0 → 0.18.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/.agents/skills/farmville-saas-ux/SKILL.md +156 -0
- package/.agents/skills/farmville-saas-ux/assets/starter.html +294 -0
- package/.agents/skills/farmville-saas-ux/references/components.md +340 -0
- package/.agents/skills/farmville-saas-ux/references/porting-guide.md +121 -0
- package/.agents/skills/farmville-saas-ux/references/tokens.md +271 -0
- package/.agents/skills/vidfarm-media/SKILL.md +4 -3
- package/.agents/skills/vidfarm-media/references/tts.md +20 -1
- package/SKILL.director.md +20 -20
- package/SKILL.platform.md +4 -4
- package/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +721 -101
- package/dist/src/cli.js +11 -10
- package/dist/src/devcli/clips.js +64 -64
- package/dist/src/editor-chat.js +2 -2
- package/dist/src/frontend/homepage-client.js +162 -2
- package/dist/src/frontend/homepage-store.js +30 -1
- package/dist/src/frontend/homepage-view.js +111 -4
- package/dist/src/homepage.js +184 -1
- package/dist/src/landing-page.js +367 -0
- package/dist/src/primitive-registry.js +278 -2
- package/dist/src/reskin/agency-page.js +299 -0
- package/dist/src/reskin/calendar-page.js +567 -0
- package/dist/src/reskin/chat-page.js +607 -0
- package/dist/src/reskin/discover-page.js +1096 -0
- package/dist/src/reskin/document.js +663 -0
- package/dist/src/reskin/help-page.js +356 -0
- package/dist/src/reskin/index-page.js +62 -0
- package/dist/src/reskin/inpaint-page.js +541 -0
- package/dist/src/reskin/job-runs-page.js +477 -0
- package/dist/src/reskin/library-page.js +688 -0
- package/dist/src/reskin/login-page.js +262 -0
- package/dist/src/reskin/pricing-page.js +388 -0
- package/dist/src/reskin/settings-page.js +687 -0
- package/dist/src/reskin/theme.js +362 -0
- package/dist/src/services/serverless-records.js +54 -0
- package/dist/src/services/swipe-customize.js +434 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +22 -22
|
@@ -7,7 +7,7 @@ import { config } from "./config.js";
|
|
|
7
7
|
import { definePrimitive } from "./primitive-sdk.js";
|
|
8
8
|
import { PrimitiveMediaLambdaService } from "./services/primitive-media-lambda.js";
|
|
9
9
|
import { estimateGhostcutCostUsd, isGhostcutConfigured, pollStatus as pollGhostcutStatus, submitSubtitleRemoval } from "./services/ghostcut.js";
|
|
10
|
-
import { buildSrtFromSegments, defaultSpeechModelFor, inferSpeechProviderForVoice, speechVoiceMismatch } from "./services/speech.js";
|
|
10
|
+
import { buildSrtFromSegments, defaultSpeechModelFor, GEMINI_TTS_VOICES, inferSpeechProviderForVoice, OPENAI_TTS_VOICES, speechVoiceFamilyFor, speechVoiceMismatch } from "./services/speech.js";
|
|
11
11
|
import { buildCaptionCues, cuesFromText } from "./services/captions.js";
|
|
12
12
|
import { CAPTION_ANIMATIONS, CAPTION_STYLE_PRESETS, captionStylePresetById } from "./hyperframes/composition.js";
|
|
13
13
|
import { buildHtmlImageCompositionHtml, buildMediaDedupeCompositionHtml, DEDUPE_DEFAULT_EFFECTS } from "./primitives/hyperframes-media.js";
|
|
@@ -460,6 +460,54 @@ const captionsPayloadSchema = z.preprocess((raw) => {
|
|
|
460
460
|
}).refine((payload) => Boolean(payload.source_url || payload.text), {
|
|
461
461
|
message: "Provide source_url (audio/video to transcribe) or text (script to page)."
|
|
462
462
|
}));
|
|
463
|
+
// Regenerate speech in (approximately) the original speaker's voice: listen to
|
|
464
|
+
// the source, transcribe + profile the voice, reword the script per the
|
|
465
|
+
// caller's instruction (or take an exact new script), then TTS with the
|
|
466
|
+
// closest preset voice + a style prompt matched to the speaker. OpenAI/Gemini
|
|
467
|
+
// APIs cannot clone voice IDENTITY (preset voices only), so the result is an
|
|
468
|
+
// honest approximation, never a clone — the output says so via voice_match.
|
|
469
|
+
const speechRegeneratePayloadSchema = z.preprocess((raw) => {
|
|
470
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
471
|
+
return raw;
|
|
472
|
+
const payload = raw;
|
|
473
|
+
return {
|
|
474
|
+
...payload,
|
|
475
|
+
source_url: payload.source_url ??
|
|
476
|
+
payload.source_video_url ??
|
|
477
|
+
payload.source_audio_url ??
|
|
478
|
+
payload.source_media_url ??
|
|
479
|
+
payload.video_url ??
|
|
480
|
+
payload.audio_url ??
|
|
481
|
+
payload.url,
|
|
482
|
+
text: payload.text ?? payload.script ?? payload.new_text,
|
|
483
|
+
rewrite_instruction: payload.rewrite_instruction ?? payload.instruction ?? payload.rewrite ?? payload.reword,
|
|
484
|
+
instructions: payload.instructions ?? payload.style ?? payload.style_instructions ?? payload.voice_style
|
|
485
|
+
};
|
|
486
|
+
}, z.object({
|
|
487
|
+
provider: speechProviderSchema.optional(),
|
|
488
|
+
model: z.string().min(1).optional(),
|
|
489
|
+
source_url: mediaSourceUrlSchema,
|
|
490
|
+
media_type: z.enum(["auto", "video", "audio"]).default("auto"),
|
|
491
|
+
/** Exact new script to speak. Mutually optional with rewrite_instruction; one is required. */
|
|
492
|
+
text: z.string().min(1).max(8000).optional(),
|
|
493
|
+
/** How to reword the original transcript (e.g. "mention the summer sale instead of the discount code"). */
|
|
494
|
+
rewrite_instruction: z.string().min(1).max(4000).optional(),
|
|
495
|
+
/** Explicit preset override; skips the profiled voice recommendation. */
|
|
496
|
+
voice: z.string().min(1).max(120).optional(),
|
|
497
|
+
/** Extra voice-style guidance appended to the profiled style instructions. */
|
|
498
|
+
instructions: z.string().min(1).max(4000).optional(),
|
|
499
|
+
language: z.string().min(2).max(16).optional(),
|
|
500
|
+
output_format: z.enum(["mp3", "wav"]).default("mp3")
|
|
501
|
+
}).refine((value) => Boolean(value.text || value.rewrite_instruction), {
|
|
502
|
+
message: "Provide text (the exact new script) or rewrite_instruction (how to reword the original transcript)."
|
|
503
|
+
}).superRefine((value, ctx) => {
|
|
504
|
+
if (!value.provider || !value.voice)
|
|
505
|
+
return;
|
|
506
|
+
const mismatch = speechVoiceMismatch({ provider: value.provider, model: value.model, voice: value.voice });
|
|
507
|
+
if (mismatch) {
|
|
508
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["voice"], message: mismatch });
|
|
509
|
+
}
|
|
510
|
+
}));
|
|
463
511
|
const probeVideoPayloadSchema = z.object({
|
|
464
512
|
source_video_url: mediaSourceUrlSchema
|
|
465
513
|
});
|
|
@@ -613,6 +661,7 @@ const PRIMITIVE_MEDIA_DEDUPE_ID = "primitive:media_dedupe";
|
|
|
613
661
|
const PRIMITIVE_TTS_ID = "primitive:tts";
|
|
614
662
|
const PRIMITIVE_STT_ID = "primitive:stt";
|
|
615
663
|
const PRIMITIVE_CAPTIONS_ID = "primitive:captions";
|
|
664
|
+
const PRIMITIVE_SPEECH_REGENERATE_ID = "primitive:speech_regenerate";
|
|
616
665
|
// Gemini inline audio caps out around 20MB; at the 64kbps mono mp3 we extract,
|
|
617
666
|
// that is roughly 40 minutes of speech.
|
|
618
667
|
const MAX_TRANSCRIBE_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
@@ -1746,6 +1795,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
|
|
|
1746
1795
|
const sourceKind = payload.media_type === "auto" ? inferSpeechSourceKind(payload.source_url) : payload.media_type;
|
|
1747
1796
|
let audioBytes;
|
|
1748
1797
|
let audioContentType;
|
|
1798
|
+
let audioUrl;
|
|
1749
1799
|
let extractionMetadata = null;
|
|
1750
1800
|
if (sourceKind === "audio") {
|
|
1751
1801
|
ctx.logger.progress(0.1, "Fetching source audio");
|
|
@@ -1755,6 +1805,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
|
|
|
1755
1805
|
}
|
|
1756
1806
|
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1757
1807
|
audioContentType = response.headers.get("content-type")?.split(";")[0]?.trim() || "audio/mpeg";
|
|
1808
|
+
audioUrl = payload.source_url;
|
|
1758
1809
|
}
|
|
1759
1810
|
else {
|
|
1760
1811
|
ctx.logger.progress(0.1, "Extracting audio track from source", {
|
|
@@ -1778,6 +1829,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
|
|
|
1778
1829
|
}
|
|
1779
1830
|
audioBytes = new Uint8Array(await response.arrayBuffer());
|
|
1780
1831
|
audioContentType = extracted.contentType || "audio/mpeg";
|
|
1832
|
+
audioUrl = extracted.publicUrl;
|
|
1781
1833
|
extractionMetadata = extracted.metadata ?? null;
|
|
1782
1834
|
}
|
|
1783
1835
|
if (audioBytes.byteLength > MAX_TRANSCRIBE_AUDIO_BYTES) {
|
|
@@ -1785,7 +1837,7 @@ async function loadSpeechAudioForPrimitive(ctx, payload) {
|
|
|
1785
1837
|
const maxMb = Math.round(MAX_TRANSCRIBE_AUDIO_BYTES / (1024 * 1024));
|
|
1786
1838
|
throw new Error(`Audio is too large to transcribe (${sizeMb}MB > ${maxMb}MB, roughly 40 minutes of speech). Trim the source with /videos/trim or /audio/trim and transcribe in parts.`);
|
|
1787
1839
|
}
|
|
1788
|
-
return { audioBytes, audioContentType, extractionMetadata };
|
|
1840
|
+
return { audioBytes, audioContentType, audioUrl, extractionMetadata };
|
|
1789
1841
|
}
|
|
1790
1842
|
const sttPrimitive = definePrimitive({
|
|
1791
1843
|
id: PRIMITIVE_STT_ID,
|
|
@@ -1991,6 +2043,229 @@ const captionsPrimitive = definePrimitive({
|
|
|
1991
2043
|
}
|
|
1992
2044
|
}
|
|
1993
2045
|
});
|
|
2046
|
+
// Voice-casting hints for the profiling prompt: the exact preset names each
|
|
2047
|
+
// provider accepts (imported lists are the guard), annotated so an audio-native
|
|
2048
|
+
// model can pick the closest match to what it hears.
|
|
2049
|
+
const OPENAI_VOICE_CASTING_HINTS = "alloy (neutral, balanced), ash (warm, mature male), ballad (calm British male), cedar (deep, steady male), coral (warm, friendly female), echo (confident male), fable (bright British, androgynous), marin (clear, upbeat female), nova (energetic young female), onyx (deep, authoritative male), sage (soft, gentle female), shimmer (crisp, expressive female), verse (versatile, expressive male)";
|
|
2050
|
+
const GEMINI_VOICE_CASTING_HINTS = "Zephyr (bright female), Puck (upbeat male), Charon (informative male), Kore (firm female), Fenrir (excitable male), Leda (youthful female), Orus (firm male), Aoede (breezy female), Callirrhoe (easy-going female), Autonoe (bright female), Enceladus (breathy male), Iapetus (clear male), Umbriel (easy-going male), Algieba (smooth male), Despina (smooth female), Erinome (clear female), Algenib (gravelly male), Rasalgethi (informative male), Laomedeia (upbeat female), Achernar (soft female), Alnilam (firm male), Schedar (even male), Gacrux (mature female), Pulcherrima (forward female), Achird (friendly male), Zubenelgenubi (casual male), Vindemiatrix (gentle female), Sadachbia (lively male), Sadaltager (knowledgeable male), Sulafat (warm female)";
|
|
2051
|
+
// Loose on purpose: a shape miss must degrade to transcript-only, never fail
|
|
2052
|
+
// the whole regeneration job.
|
|
2053
|
+
const speechVoiceProfileResultSchema = z.object({
|
|
2054
|
+
transcript: z.string().optional().default(""),
|
|
2055
|
+
language: z.string().nullish(),
|
|
2056
|
+
voice_profile: z.object({
|
|
2057
|
+
gender: z.string().nullish(),
|
|
2058
|
+
age_range: z.string().nullish(),
|
|
2059
|
+
accent: z.string().nullish(),
|
|
2060
|
+
pace: z.string().nullish(),
|
|
2061
|
+
pitch: z.string().nullish(),
|
|
2062
|
+
energy: z.string().nullish(),
|
|
2063
|
+
tone: z.string().nullish()
|
|
2064
|
+
}).nullish(),
|
|
2065
|
+
style_instructions: z.string().nullish(),
|
|
2066
|
+
recommended_voice: z.object({
|
|
2067
|
+
openai: z.string().nullish(),
|
|
2068
|
+
gemini: z.string().nullish()
|
|
2069
|
+
}).nullish()
|
|
2070
|
+
});
|
|
2071
|
+
const speechRewriteResultSchema = z.object({
|
|
2072
|
+
script: z.string().min(1)
|
|
2073
|
+
});
|
|
2074
|
+
function buildVoiceProfilePrompt(language) {
|
|
2075
|
+
return [
|
|
2076
|
+
"You are a professional voice-casting director. Listen to the attached audio.",
|
|
2077
|
+
"Profile the dominant speaker and transcribe their words verbatim (if several people speak, profile only the dominant speaker and transcribe only their words).",
|
|
2078
|
+
"Return STRICT JSON ONLY with this exact shape:",
|
|
2079
|
+
`{"transcript": "<verbatim words spoken>", "language": "<BCP-47 code or null>", "voice_profile": {"gender": "...", "age_range": "...", "accent": "...", "pace": "...", "pitch": "...", "energy": "...", "tone": "..."}, "style_instructions": "<2-4 sentences>", "recommended_voice": {"openai": "<one name>", "gemini": "<one name>"}}`,
|
|
2080
|
+
"style_instructions must be a dense free-form text-to-speech voice direction that would make a voice actor sound as close as possible to this speaker: accent, pacing, pitch, energy, emotion, attitude, and delivery quirks (pauses, emphasis, breathiness, smile, hesitations).",
|
|
2081
|
+
`recommended_voice.openai must be exactly one name from: ${OPENAI_VOICE_CASTING_HINTS}.`,
|
|
2082
|
+
`recommended_voice.gemini must be exactly one name from: ${GEMINI_VOICE_CASTING_HINTS}.`,
|
|
2083
|
+
"Pick the preset closest to the speaker's gender, age, and timbre.",
|
|
2084
|
+
...(language?.trim() ? [`Expected language: ${language.trim()}.`] : [])
|
|
2085
|
+
].join("\n");
|
|
2086
|
+
}
|
|
2087
|
+
function buildSpeechRewritePrompt(input) {
|
|
2088
|
+
return [
|
|
2089
|
+
"You rewrite spoken-word scripts. Rewrite the original transcript below, following the instruction, as words to be SPOKEN aloud by the same person.",
|
|
2090
|
+
"Rules:",
|
|
2091
|
+
"- Keep roughly the same length and rhythm as the original unless the instruction says otherwise (the new audio should fill a similar duration).",
|
|
2092
|
+
"- Keep the speaker's register, energy, and phrasing style; this must sound like the same person talking.",
|
|
2093
|
+
"- Plain spoken words only: no stage directions, no markdown, no quotes, no speaker labels, no timestamps.",
|
|
2094
|
+
...(input.language?.trim() ? [`- Write the script in this language: ${input.language.trim()}.`] : ["- Write in the same language as the original transcript."]),
|
|
2095
|
+
`Return STRICT JSON ONLY with shape {"script": "..."}.`,
|
|
2096
|
+
"",
|
|
2097
|
+
`Instruction: ${input.instruction}`,
|
|
2098
|
+
"",
|
|
2099
|
+
"Original transcript:",
|
|
2100
|
+
input.transcript
|
|
2101
|
+
].join("\n");
|
|
2102
|
+
}
|
|
2103
|
+
// Voice-matched speech regeneration ("same voice, new words"). Pipeline:
|
|
2104
|
+
// (1) load the source audio (videos are demuxed), (2) profile the speaker with
|
|
2105
|
+
// an audio-native LLM — verbatim transcript + voice profile + closest preset
|
|
2106
|
+
// voices in ONE call (needs a saved Gemini key; without one this degrades to a
|
|
2107
|
+
// plain STT transcript and no voice match), (3) reword the transcript per
|
|
2108
|
+
// rewrite_instruction unless an exact `text` script was given, (4) TTS with
|
|
2109
|
+
// the matched preset + profiled style instructions. Neither OpenAI nor Gemini
|
|
2110
|
+
// exposes voice cloning through their public APIs (preset voices only; actual
|
|
2111
|
+
// cloning is sales-allowlisted at both), so voice_match is always an honest
|
|
2112
|
+
// approximation, never a clone.
|
|
2113
|
+
const speechRegeneratePrimitive = definePrimitive({
|
|
2114
|
+
id: PRIMITIVE_SPEECH_REGENERATE_ID,
|
|
2115
|
+
kind: "speech_regenerate",
|
|
2116
|
+
operations: {
|
|
2117
|
+
run: {
|
|
2118
|
+
description: "Regenerate the speech of a video/audio source as new AI audio that sounds like the original speaker (closest preset voice + matched style direction — an approximation, not a clone), speaking a reworded or replacement script.",
|
|
2119
|
+
inputSchema: speechRegeneratePayloadSchema,
|
|
2120
|
+
workflow: "run"
|
|
2121
|
+
}
|
|
2122
|
+
},
|
|
2123
|
+
jobs: {
|
|
2124
|
+
async run(ctx, input) {
|
|
2125
|
+
const payload = speechRegeneratePayloadSchema.parse(input);
|
|
2126
|
+
const { audioBytes, audioContentType, audioUrl } = await loadSpeechAudioForPrimitive(ctx, payload);
|
|
2127
|
+
// Step 1 — hear the speaker. One Gemini call returns transcript + voice
|
|
2128
|
+
// profile together; a failure here must never sink the job.
|
|
2129
|
+
const available = ctx.providers.listAvailableProviders();
|
|
2130
|
+
let profile = null;
|
|
2131
|
+
if (available.includes("gemini") && audioUrl) {
|
|
2132
|
+
ctx.logger.progress(0.3, "Profiling the original voice");
|
|
2133
|
+
try {
|
|
2134
|
+
const profiled = await runBrainstormJson(ctx, {
|
|
2135
|
+
kind: "speech_voice_profile",
|
|
2136
|
+
provider: "gemini",
|
|
2137
|
+
prompt: buildVoiceProfilePrompt(payload.language),
|
|
2138
|
+
attachments: [audioUrl],
|
|
2139
|
+
resultSchema: speechVoiceProfileResultSchema
|
|
2140
|
+
});
|
|
2141
|
+
profile = profiled.result;
|
|
2142
|
+
}
|
|
2143
|
+
catch (error) {
|
|
2144
|
+
ctx.logger.warn("Voice profiling failed; continuing without a voice match", {
|
|
2145
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
let originalTranscript = profile?.transcript?.trim() || "";
|
|
2150
|
+
if (!originalTranscript && payload.rewrite_instruction) {
|
|
2151
|
+
ctx.logger.progress(0.4, "Transcribing original speech");
|
|
2152
|
+
const sttProvider = ["gemini", "openai", "openrouter"].find((provider) => available.includes(provider)) ?? "gemini";
|
|
2153
|
+
const transcription = await ctx.providers.transcribeSpeech({
|
|
2154
|
+
provider: sttProvider,
|
|
2155
|
+
model: defaultSpeechModelFor("stt", sttProvider) ?? "",
|
|
2156
|
+
audio: audioBytes,
|
|
2157
|
+
contentType: audioContentType,
|
|
2158
|
+
language: payload.language,
|
|
2159
|
+
diarize: false
|
|
2160
|
+
});
|
|
2161
|
+
originalTranscript = transcription.text.trim();
|
|
2162
|
+
if (!originalTranscript) {
|
|
2163
|
+
throw new Error("The source has no transcribable speech to reword. Pass text with the exact script instead.");
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
// Step 2 — the words: exact script wins, otherwise reword the transcript.
|
|
2167
|
+
let script = payload.text?.trim() ?? "";
|
|
2168
|
+
if (!script) {
|
|
2169
|
+
ctx.logger.progress(0.5, "Rewording the script");
|
|
2170
|
+
const rewrite = await runBrainstormJson(ctx, {
|
|
2171
|
+
kind: "speech_rewrite",
|
|
2172
|
+
prompt: buildSpeechRewritePrompt({
|
|
2173
|
+
transcript: originalTranscript,
|
|
2174
|
+
instruction: payload.rewrite_instruction,
|
|
2175
|
+
language: payload.language ?? profile?.language ?? null
|
|
2176
|
+
}),
|
|
2177
|
+
resultSchema: speechRewriteResultSchema
|
|
2178
|
+
});
|
|
2179
|
+
script = rewrite.result.script.trim();
|
|
2180
|
+
}
|
|
2181
|
+
if (!script) {
|
|
2182
|
+
throw new Error("No script produced to speak.");
|
|
2183
|
+
}
|
|
2184
|
+
if (script.length > 8000) {
|
|
2185
|
+
throw new Error("The regenerated script is too long for TTS (8000 characters max). Trim the source with /audio/trim or narrow the rewrite_instruction.");
|
|
2186
|
+
}
|
|
2187
|
+
// Step 3 — voice + style. openai first when nothing else decides: its
|
|
2188
|
+
// TTS takes the style `instructions` natively (gemini gets them
|
|
2189
|
+
// prepended into the prompt). A bare payload voice implies its provider.
|
|
2190
|
+
const provider = payload.provider
|
|
2191
|
+
?? inferSpeechProviderForVoice(payload.voice)
|
|
2192
|
+
?? ["openai", "gemini", "openrouter"].find((candidate) => available.includes(candidate))
|
|
2193
|
+
?? "openai";
|
|
2194
|
+
const model = payload.model ?? defaultSpeechModelFor("tts", provider) ?? "";
|
|
2195
|
+
const family = speechVoiceFamilyFor(provider, model);
|
|
2196
|
+
const presets = family === "gemini" ? GEMINI_TTS_VOICES : OPENAI_TTS_VOICES;
|
|
2197
|
+
const recommendedRaw = (family === "gemini" ? profile?.recommended_voice?.gemini : profile?.recommended_voice?.openai)?.trim().toLowerCase();
|
|
2198
|
+
const recommendedVoice = recommendedRaw ? presets.find((preset) => preset.toLowerCase() === recommendedRaw) : undefined;
|
|
2199
|
+
const voice = payload.voice ?? recommendedVoice;
|
|
2200
|
+
const profiledStyle = profile?.style_instructions?.trim();
|
|
2201
|
+
const instructions = [profiledStyle, payload.instructions?.trim()].filter(Boolean).join(" ") || undefined;
|
|
2202
|
+
ctx.logger.progress(0.65, "Generating voice-matched speech");
|
|
2203
|
+
const generated = await ctx.providers.generateSpeech({
|
|
2204
|
+
provider,
|
|
2205
|
+
model,
|
|
2206
|
+
text: script,
|
|
2207
|
+
voice,
|
|
2208
|
+
instructions,
|
|
2209
|
+
responseFormat: payload.output_format
|
|
2210
|
+
});
|
|
2211
|
+
const extension = generated.contentType.includes("wav") ? "wav" : "mp3";
|
|
2212
|
+
const stored = await ctx.storage.putBuffer(`speech.${extension}`, generated.bytes, {
|
|
2213
|
+
contentType: generated.contentType,
|
|
2214
|
+
kind: "audio",
|
|
2215
|
+
metadata: {
|
|
2216
|
+
primitive: "speech_regenerate",
|
|
2217
|
+
provider,
|
|
2218
|
+
model,
|
|
2219
|
+
voice: voice ?? null,
|
|
2220
|
+
styleInstructions: instructions ?? null
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
ctx.logger.progress(0.9, "Storing regeneration artifacts");
|
|
2224
|
+
const voiceMatch = profiledStyle || recommendedVoice ? "approximate" : "none";
|
|
2225
|
+
const voiceMatchNote = voiceMatch === "approximate"
|
|
2226
|
+
? "The audio uses the closest preset voice with style instructions matched to the original speaker. OpenAI/Gemini APIs cannot clone voice identity, so this is an approximation, not a clone."
|
|
2227
|
+
: "No voice profile was available (profiling needs a saved Gemini key), so a default or explicitly requested preset voice was used without matching the original speaker.";
|
|
2228
|
+
const storedJson = await ctx.storage.putJson("regenerate-speech.json", {
|
|
2229
|
+
source_url: payload.source_url,
|
|
2230
|
+
provider,
|
|
2231
|
+
model,
|
|
2232
|
+
voice: voice ?? null,
|
|
2233
|
+
style_instructions: instructions ?? null,
|
|
2234
|
+
voice_match: voiceMatch,
|
|
2235
|
+
voice_match_note: voiceMatchNote,
|
|
2236
|
+
voice_profile: profile?.voice_profile ?? null,
|
|
2237
|
+
original_transcript: originalTranscript || null,
|
|
2238
|
+
rewrite_instruction: payload.rewrite_instruction ?? null,
|
|
2239
|
+
script,
|
|
2240
|
+
audio_url: stored.url
|
|
2241
|
+
});
|
|
2242
|
+
ctx.logger.progress(1, "Speech regeneration complete");
|
|
2243
|
+
return {
|
|
2244
|
+
progress: 1,
|
|
2245
|
+
output: {
|
|
2246
|
+
files: compactUrls([stored.url, storedJson.url]),
|
|
2247
|
+
primary_file_url: stored.url,
|
|
2248
|
+
audioUrl: stored.url,
|
|
2249
|
+
script,
|
|
2250
|
+
original_transcript: originalTranscript ? originalTranscript.slice(0, MAX_INLINE_TRANSCRIPT_CHARS) : null,
|
|
2251
|
+
voice_match: voiceMatch,
|
|
2252
|
+
voice_match_note: voiceMatchNote,
|
|
2253
|
+
voice_profile: profile?.voice_profile ?? null,
|
|
2254
|
+
audio: {
|
|
2255
|
+
file_url: stored.url,
|
|
2256
|
+
content_type: generated.contentType,
|
|
2257
|
+
provider,
|
|
2258
|
+
model,
|
|
2259
|
+
voice: voice ?? null,
|
|
2260
|
+
style_instructions: instructions ?? null,
|
|
2261
|
+
text_length: script.length
|
|
2262
|
+
},
|
|
2263
|
+
details_json_url: storedJson.url
|
|
2264
|
+
}
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
});
|
|
1994
2269
|
const videoProbePrimitive = definePrimitive({
|
|
1995
2270
|
id: PRIMITIVE_VIDEO_PROBE_ID,
|
|
1996
2271
|
kind: "video_probe",
|
|
@@ -2570,6 +2845,7 @@ class PrimitiveRegistry {
|
|
|
2570
2845
|
[ttsPrimitive.id, ttsPrimitive],
|
|
2571
2846
|
[sttPrimitive.id, sttPrimitive],
|
|
2572
2847
|
[captionsPrimitive.id, captionsPrimitive],
|
|
2848
|
+
[speechRegeneratePrimitive.id, speechRegeneratePrimitive],
|
|
2573
2849
|
[hyperframesRenderPrimitive.id, hyperframesRenderPrimitive],
|
|
2574
2850
|
[brainstormHooksPrimitive.id, brainstormHooksPrimitive],
|
|
2575
2851
|
[brainstormAwarenessStagesPrimitive.id, brainstormAwarenessStagesPrimitive],
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// /reskin/agency — the Agency / client-workspaces screen, MIGRATED to real
|
|
2
|
+
// production data + behavior (farmville design system).
|
|
3
|
+
//
|
|
4
|
+
// This mirrors src/account-pages-legacy.ts → renderAgencyPage: an agency
|
|
5
|
+
// managing its client sub-accounts. The live page's client list is NOT
|
|
6
|
+
// server-fetched — it is read CLIENT-SIDE from this browser's localStorage
|
|
7
|
+
// account registry (the same cache the app writes when you sign into a client
|
|
8
|
+
// account), so the server can only render an empty shell + template and the
|
|
9
|
+
// real client cards are built in JS. This reskin replicates that exact read and
|
|
10
|
+
// wires the real per-client actions:
|
|
11
|
+
// • "Open workspace" → opens the client's /u/:id/library in a new tab
|
|
12
|
+
// • "Copy login" → copies the client's autologin URL
|
|
13
|
+
// • "Add client login"→ opens /agency-client-login
|
|
14
|
+
// localStorage contract (identical keys/fields to the live page):
|
|
15
|
+
// index key: "vidfarm:agency:accounts:index" → string[] of userIds
|
|
16
|
+
// entry key: "vidfarm:agency:account_<userId>" → { apiKey, email,
|
|
17
|
+
// displayName, lastSeenAt, autologinUrl? }
|
|
18
|
+
// autologinUrl falls back to "/auto-login?api_key=<encodeURIComponent(apiKey)>"
|
|
19
|
+
//
|
|
20
|
+
// The old reskin rendered fabricated per-client stats (projects, scheduled,
|
|
21
|
+
// wallet, seats, plan, status) and aggregate tiles — NONE of those live in the
|
|
22
|
+
// cache, so they are dropped. Cards now show only real cached fields (name,
|
|
23
|
+
// email, user id, last-seen). Avatars are text initials (no emoji).
|
|
24
|
+
import { reskinDocument, rkEscape } from "./document.js";
|
|
25
|
+
export function renderReskinAgency(input = { userId: null, currentAccountId: null, name: null, email: null, loggedIn: false }) {
|
|
26
|
+
const loggedIn = input.loggedIn ?? Boolean(input.userId);
|
|
27
|
+
const signedInHint = loggedIn && input.email
|
|
28
|
+
? `<span class="rk-agency-hint">Managing from ${rkEscape(input.email)}</span>`
|
|
29
|
+
: "";
|
|
30
|
+
const PLUS = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>`;
|
|
31
|
+
// The invite / "add a client workspace" card is a static, always-real CTA to
|
|
32
|
+
// the /agency-client-login flow. It lives inside the grid alongside the
|
|
33
|
+
// JS-injected client cards (which render into [data-rk-agency-cards]).
|
|
34
|
+
const inviteCard = `<article class="rk-card rk-agency-invite" data-rk-agency-invite>
|
|
35
|
+
<span class="rk-tile rk-tile-lg rk-tile-gold rk-agency-invite-ic">${PLUS}</span>
|
|
36
|
+
<h3 class="rk-agency-invite-title">Add a client workspace</h3>
|
|
37
|
+
<p class="rk-muted rk-agency-invite-copy">Link a client's account and get an autologin so your team can post on their behalf.</p>
|
|
38
|
+
<a class="rk-btn rk-btn-gold" href="/agency-client-login" target="_blank" rel="noreferrer">Add client login <span class="rk-arrow">→</span></a>
|
|
39
|
+
</article>`;
|
|
40
|
+
const body = `
|
|
41
|
+
<main class="rk-container rk-page">
|
|
42
|
+
<div class="rk-page-head rk-agency-head">
|
|
43
|
+
<div class="rk-agency-head-copy">
|
|
44
|
+
<h1 class="rk-h1">Agency clients</h1>
|
|
45
|
+
<p class="rk-sub">Every client workspace cached on this device — open their library, or copy a one-tap login to post on their behalf.</p>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="rk-agency-head-cta">
|
|
48
|
+
${signedInHint}
|
|
49
|
+
<a class="rk-btn rk-btn-gold rk-btn-lg" href="/agency-client-login" target="_blank" rel="noreferrer">Add client login <span class="rk-arrow">→</span></a>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<section class="rk-agency-panel">
|
|
54
|
+
<div class="rk-spread rk-wrap rk-agency-toolbar">
|
|
55
|
+
<div class="rk-agency-toolbar-copy">
|
|
56
|
+
<span class="rk-eyebrow">Clients</span>
|
|
57
|
+
<h2 class="rk-agency-h2"><span data-rk-agency-count>0</span> client workspace<span data-rk-agency-plural>s</span></h2>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="rk-agency-search-wrap">
|
|
60
|
+
<span class="rk-agency-search-ic" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg></span>
|
|
61
|
+
<input class="rk-input rk-agency-search" type="search" placeholder="Search name, email, or user id" autocomplete="off" spellcheck="false" data-rk-agency-search />
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="rk-grid rk-grid-3 rk-agency-grid" data-rk-agency-grid>
|
|
65
|
+
<div class="rk-agency-cards" data-rk-agency-cards></div>
|
|
66
|
+
${inviteCard}
|
|
67
|
+
</div>
|
|
68
|
+
<div class="rk-agency-empty" data-rk-agency-empty hidden>
|
|
69
|
+
<p class="rk-muted" data-rk-agency-empty-msg>No cached accounts found in this browser yet.</p>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
</main>`;
|
|
73
|
+
const pageCss = `
|
|
74
|
+
.rk-agency-head{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:20px}
|
|
75
|
+
.rk-agency-head-copy{display:grid;gap:4px;min-width:0}
|
|
76
|
+
.rk-agency-head-cta{display:flex;align-items:center;gap:12px;flex:none}
|
|
77
|
+
.rk-agency-hint{font-size:13px;color:var(--rk-text-muted);white-space:nowrap}
|
|
78
|
+
@media(max-width:760px){.rk-agency-head{grid-template-columns:1fr}.rk-agency-head-cta{flex-wrap:wrap}}
|
|
79
|
+
|
|
80
|
+
.rk-agency-panel{background:var(--rk-bg-section);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);padding:24px}
|
|
81
|
+
.rk-agency-toolbar{margin-bottom:20px}
|
|
82
|
+
.rk-agency-toolbar-copy{display:grid;gap:4px}
|
|
83
|
+
.rk-agency-h2{font-size:var(--rk-text-2xl);letter-spacing:-.02em;color:var(--rk-ink)}
|
|
84
|
+
.rk-agency-search-wrap{position:relative;width:min(360px,100%)}
|
|
85
|
+
.rk-agency-search-ic{position:absolute;left:15px;top:50%;transform:translateY(-50%);font-size:14px;opacity:.6;pointer-events:none}
|
|
86
|
+
.rk-agency-search{padding-left:40px;background:#fff;border-radius:var(--rk-r-full)}
|
|
87
|
+
@media(max-width:620px){.rk-agency-search-wrap{width:100%}}
|
|
88
|
+
|
|
89
|
+
.rk-agency-grid{align-items:stretch}
|
|
90
|
+
/* the JS card wrapper is transparent to the grid so cards + invite share tracks */
|
|
91
|
+
.rk-agency-cards{display:contents}
|
|
92
|
+
.rk-agency-card{padding:22px;display:flex;flex-direction:column;gap:16px}
|
|
93
|
+
.rk-agency-card-top{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:start}
|
|
94
|
+
.rk-agency-avatar{font-family:var(--rk-font-display);font-weight:800;font-size:22px}
|
|
95
|
+
.rk-agency-card-id{min-width:0;display:grid;gap:2px}
|
|
96
|
+
.rk-agency-name{font-size:var(--rk-text-lg);font-weight:800;font-family:var(--rk-font-display);color:var(--rk-ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
97
|
+
.rk-agency-email{font-size:13px;color:var(--rk-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;transition:color var(--rk-dur) var(--rk-ease)}
|
|
98
|
+
.rk-agency-email:hover{color:var(--rk-gold-700)}
|
|
99
|
+
.rk-agency-seen-pill{flex:none;align-self:start;white-space:nowrap}
|
|
100
|
+
.rk-agency-idrow{display:flex}
|
|
101
|
+
.rk-agency-user{font-family:var(--rk-font-mono);font-size:12px;color:var(--rk-text-faint);cursor:pointer;transition:color var(--rk-dur) var(--rk-ease);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
102
|
+
.rk-agency-user:hover{color:var(--rk-n-600)}
|
|
103
|
+
.rk-agency-card-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:auto}
|
|
104
|
+
.rk-agency-open{font-weight:600}
|
|
105
|
+
|
|
106
|
+
.rk-agency-invite{border-style:dashed;border-color:var(--rk-border-strong);background:linear-gradient(180deg,var(--rk-gold-tint),#fff 70%);display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:240px}
|
|
107
|
+
.rk-agency-invite-ic{display:grid;place-items:center}
|
|
108
|
+
.rk-agency-invite-title{font-size:var(--rk-text-lg);font-family:var(--rk-font-display);font-weight:800;color:var(--rk-ink)}
|
|
109
|
+
.rk-agency-invite-copy{font-size:13px;max-width:26ch}
|
|
110
|
+
|
|
111
|
+
.rk-agency-empty{display:none;flex-direction:column;align-items:center;gap:12px;padding:56px 20px;text-align:center}
|
|
112
|
+
.rk-agency-empty[data-rk-shown="1"]{display:flex}
|
|
113
|
+
`;
|
|
114
|
+
// Client-side: replicate the live localStorage account-registry read, build
|
|
115
|
+
// real farmville client cards, and wire the real actions. Written with string
|
|
116
|
+
// concatenation (not template literals) to avoid nested-`${}` escaping.
|
|
117
|
+
const script = `
|
|
118
|
+
(function(){
|
|
119
|
+
var ACCOUNT_REGISTRY_INDEX_KEY = "vidfarm:agency:accounts:index";
|
|
120
|
+
var ACCOUNT_REGISTRY_ENTRY_PREFIX = "vidfarm:agency:account_";
|
|
121
|
+
var TINTS = ["gold","sky","violet","green","coral","ink"];
|
|
122
|
+
|
|
123
|
+
var root=document;
|
|
124
|
+
var cardsWrap=root.querySelector('[data-rk-agency-cards]');
|
|
125
|
+
var invite=root.querySelector('[data-rk-agency-invite]');
|
|
126
|
+
var search=root.querySelector('[data-rk-agency-search]');
|
|
127
|
+
var empty=root.querySelector('[data-rk-agency-empty]');
|
|
128
|
+
var emptyMsg=root.querySelector('[data-rk-agency-empty-msg]');
|
|
129
|
+
var countEl=root.querySelector('[data-rk-agency-count]');
|
|
130
|
+
var pluralEl=root.querySelector('[data-rk-agency-plural]');
|
|
131
|
+
var searchQuery="";
|
|
132
|
+
|
|
133
|
+
function escapeHtml(value){
|
|
134
|
+
return String(value==null?"":value)
|
|
135
|
+
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
|
136
|
+
.replace(/"/g,""").replace(/'/g,"'");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function initials(name){
|
|
140
|
+
var letters=String(name||"").replace(/&/g," ").split(/\\s+/)
|
|
141
|
+
.map(function(w){var m=w.match(/[a-z0-9]/i);return m?m[0]:"";})
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
var out=(letters[0]||"")+(letters[1]||"");
|
|
144
|
+
return (out||"?").toUpperCase();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tintFor(seed){
|
|
148
|
+
var s=String(seed||""),h=0;
|
|
149
|
+
for(var i=0;i<s.length;i++){h=(h*31+s.charCodeAt(i))>>>0;}
|
|
150
|
+
return TINTS[h%TINTS.length];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function relTime(iso){
|
|
154
|
+
var t=Date.parse(iso||"");
|
|
155
|
+
if(isNaN(t))return "";
|
|
156
|
+
var diff=Date.now()-t; if(diff<0)diff=0;
|
|
157
|
+
var m=Math.floor(diff/60000);
|
|
158
|
+
if(m<1)return "just now";
|
|
159
|
+
if(m<60)return m+"m ago";
|
|
160
|
+
var hr=Math.floor(m/60);
|
|
161
|
+
if(hr<24)return hr+"h ago";
|
|
162
|
+
var d=Math.floor(hr/24);
|
|
163
|
+
if(d<7)return d+"d ago";
|
|
164
|
+
try{return new Date(t).toLocaleDateString("en-US",{month:"short",day:"numeric"});}catch(_){return d+"d ago";}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readAccountIds(){
|
|
168
|
+
try{
|
|
169
|
+
var raw=window.localStorage.getItem(ACCOUNT_REGISTRY_INDEX_KEY);
|
|
170
|
+
var parsed=raw?JSON.parse(raw):[];
|
|
171
|
+
return Array.isArray(parsed)?parsed.filter(function(v){return typeof v==="string"&&v;}):[];
|
|
172
|
+
}catch(_){return [];}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readAccounts(){
|
|
176
|
+
return readAccountIds().map(function(userId){
|
|
177
|
+
try{
|
|
178
|
+
var raw=window.localStorage.getItem(ACCOUNT_REGISTRY_ENTRY_PREFIX+userId);
|
|
179
|
+
if(!raw)return null;
|
|
180
|
+
var parsed=JSON.parse(raw);
|
|
181
|
+
if(!parsed||typeof parsed!=="object"||typeof parsed.apiKey!=="string"||!parsed.apiKey)return null;
|
|
182
|
+
return {
|
|
183
|
+
userId:userId,
|
|
184
|
+
email:typeof parsed.email==="string"?parsed.email:"",
|
|
185
|
+
displayName:typeof parsed.displayName==="string"?parsed.displayName:"",
|
|
186
|
+
lastSeenAt:typeof parsed.lastSeenAt==="string"?parsed.lastSeenAt:"",
|
|
187
|
+
autologinUrl:typeof parsed.autologinUrl==="string"?parsed.autologinUrl:"/auto-login?api_key="+encodeURIComponent(parsed.apiKey)
|
|
188
|
+
};
|
|
189
|
+
}catch(_){return null;}
|
|
190
|
+
}).filter(Boolean).sort(function(a,b){return Date.parse(b.lastSeenAt||"")-Date.parse(a.lastSeenAt||"");});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function matchesSearch(account){
|
|
194
|
+
var needle=searchQuery.trim().toLowerCase();
|
|
195
|
+
if(!needle)return true;
|
|
196
|
+
return [account.displayName,account.email,account.userId]
|
|
197
|
+
.some(function(v){return String(v||"").toLowerCase().indexOf(needle)!==-1;});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function cardHtml(account){
|
|
201
|
+
var title=account.displayName||account.email||account.userId;
|
|
202
|
+
var tint=tintFor(account.userId);
|
|
203
|
+
var seen=relTime(account.lastSeenAt);
|
|
204
|
+
var seenPill=seen?'<span class="rk-pill rk-agency-seen-pill">Active '+escapeHtml(seen)+'</span>':'';
|
|
205
|
+
var openTarget="/u/"+encodeURIComponent(account.userId)+"/library";
|
|
206
|
+
return ''
|
|
207
|
+
+'<article class="rk-card rk-card-hover rk-agency-card">'
|
|
208
|
+
+'<div class="rk-agency-card-top">'
|
|
209
|
+
+'<span class="rk-tile rk-tile-lg rk-tile-'+tint+' rk-agency-avatar">'+escapeHtml(initials(title))+'</span>'
|
|
210
|
+
+'<div class="rk-agency-card-id">'
|
|
211
|
+
+'<h3 class="rk-agency-name">'+escapeHtml(title)+'</h3>'
|
|
212
|
+
+'<div class="rk-agency-email" role="button" tabindex="0" data-rk-copy-text="'+escapeHtml(account.email||"")+'" title="Copy email">'+escapeHtml(account.email||"No email")+'</div>'
|
|
213
|
+
+'</div>'
|
|
214
|
+
+seenPill
|
|
215
|
+
+'</div>'
|
|
216
|
+
+'<div class="rk-agency-idrow">'
|
|
217
|
+
+'<span class="rk-agency-user" role="button" tabindex="0" data-rk-copy-text="'+escapeHtml(account.userId)+'" title="Copy user id">User '+escapeHtml(account.userId)+'</span>'
|
|
218
|
+
+'</div>'
|
|
219
|
+
+'<div class="rk-agency-card-foot">'
|
|
220
|
+
+'<button class="rk-btn rk-btn-ink rk-btn-sm rk-agency-open" type="button" data-rk-open-tab="'+escapeHtml(openTarget)+'">Open workspace <span class="rk-arrow">→</span></button>'
|
|
221
|
+
+'<button class="rk-btn rk-btn-ghost rk-btn-sm" type="button" data-rk-copy-login="'+escapeHtml(account.autologinUrl)+'">Copy login</button>'
|
|
222
|
+
+'</div>'
|
|
223
|
+
+'</article>';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function flash(el,label){
|
|
227
|
+
if(!el)return;
|
|
228
|
+
var prev=el.textContent;
|
|
229
|
+
el.textContent=label;
|
|
230
|
+
window.setTimeout(function(){el.textContent=prev;},1300);
|
|
231
|
+
}
|
|
232
|
+
function copy(value,el){
|
|
233
|
+
if(!value)return;
|
|
234
|
+
if(navigator.clipboard&&navigator.clipboard.writeText){
|
|
235
|
+
navigator.clipboard.writeText(value).then(function(){flash(el,"Copied");},function(){flash(el,"Clipboard blocked");});
|
|
236
|
+
}else{flash(el,"Clipboard blocked");}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function wire(){
|
|
240
|
+
if(!cardsWrap)return;
|
|
241
|
+
cardsWrap.querySelectorAll('[data-rk-copy-text]').forEach(function(el){
|
|
242
|
+
var run=function(){copy(el.getAttribute('data-rk-copy-text'),el);};
|
|
243
|
+
el.addEventListener('click',run);
|
|
244
|
+
el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();run();}});
|
|
245
|
+
});
|
|
246
|
+
cardsWrap.querySelectorAll('[data-rk-copy-login]').forEach(function(btn){
|
|
247
|
+
btn.addEventListener('click',function(){
|
|
248
|
+
var rel=btn.getAttribute('data-rk-copy-login')||'';
|
|
249
|
+
var abs=rel;try{abs=new URL(rel,window.location.origin).toString();}catch(_){}
|
|
250
|
+
copy(abs,btn);
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
cardsWrap.querySelectorAll('[data-rk-open-tab]').forEach(function(btn){
|
|
254
|
+
btn.addEventListener('click',function(){
|
|
255
|
+
var target=btn.getAttribute('data-rk-open-tab');
|
|
256
|
+
if(target)window.open(target,'_blank','noopener,noreferrer');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function render(){
|
|
262
|
+
if(!cardsWrap)return;
|
|
263
|
+
var accounts=readAccounts();
|
|
264
|
+
var total=accounts.length;
|
|
265
|
+
if(countEl)countEl.textContent=String(total);
|
|
266
|
+
if(pluralEl)pluralEl.textContent=total===1?'':'s';
|
|
267
|
+
|
|
268
|
+
var filtered=accounts.filter(matchesSearch);
|
|
269
|
+
cardsWrap.innerHTML=filtered.map(cardHtml).join('');
|
|
270
|
+
wire();
|
|
271
|
+
|
|
272
|
+
// hide the "add client" invite while actively searching (it isn't a result)
|
|
273
|
+
if(invite)invite.hidden=!!searchQuery.trim();
|
|
274
|
+
|
|
275
|
+
if(empty&&emptyMsg){
|
|
276
|
+
var showEmpty=false,msg='';
|
|
277
|
+
if(total===0){showEmpty=true;msg='No cached accounts found in this browser yet.';}
|
|
278
|
+
else if(filtered.length===0){showEmpty=true;msg='No agency clients match that search.';}
|
|
279
|
+
emptyMsg.textContent=msg;
|
|
280
|
+
empty.hidden=!showEmpty;
|
|
281
|
+
empty.setAttribute('data-rk-shown',showEmpty?'1':'0');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if(search){
|
|
286
|
+
search.addEventListener('input',function(){searchQuery=search.value||'';render();});
|
|
287
|
+
}
|
|
288
|
+
render();
|
|
289
|
+
})();`;
|
|
290
|
+
return reskinDocument({
|
|
291
|
+
title: "vidfarm reskin — Agency",
|
|
292
|
+
description: "Reskinned vidfarm agency page — cached client workspaces, open library, copy autologin.",
|
|
293
|
+
activeSlug: "agency",
|
|
294
|
+
pageCss,
|
|
295
|
+
body,
|
|
296
|
+
script
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=agency-page.js.map
|