@mevdragon/vidfarm-devcli 0.16.0 → 0.17.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 +2 -1
- package/.agents/skills/vidfarm-media/references/tts.md +20 -1
- package/dist/src/account-pages-legacy.js +2 -2
- package/dist/src/app.js +441 -77
- 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 +255 -0
- package/dist/src/reskin/calendar-page.js +252 -0
- package/dist/src/reskin/chat-page.js +414 -0
- package/dist/src/reskin/discover-page.js +380 -0
- package/dist/src/reskin/document.js +74 -0
- package/dist/src/reskin/help-page.js +318 -0
- package/dist/src/reskin/index-page.js +62 -0
- package/dist/src/reskin/job-runs-page.js +249 -0
- package/dist/src/reskin/library-page.js +515 -0
- package/dist/src/reskin/login-page.js +225 -0
- package/dist/src/reskin/pricing-page.js +359 -0
- package/dist/src/reskin/settings-page.js +353 -0
- package/dist/src/reskin/theme.js +265 -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,255 @@
|
|
|
1
|
+
// /reskin/agency — the Agency / client-workspaces screen ported into the
|
|
2
|
+
// farmville design system. Uses ONLY shared rk-* primitives + a small
|
|
3
|
+
// page-scoped CSS block + a self-contained module script.
|
|
4
|
+
//
|
|
5
|
+
// Renders with representative SAMPLE data so it can be viewed without auth.
|
|
6
|
+
// Faithfully mirrors src/account-pages-legacy.ts → renderAgencyPage: an agency
|
|
7
|
+
// managing its client sub-accounts. The legacy page is a searchable list of
|
|
8
|
+
// cached client accounts (name · email · user id) with "Open Tab" (opens the
|
|
9
|
+
// client's /u/:id/library) and "Copy Login" (autologin URL) actions plus an
|
|
10
|
+
// "Add Login" button. This reskin keeps that spine and dresses it up with the
|
|
11
|
+
// per-client stats the admin agency-link model already tracks (wallet, seats,
|
|
12
|
+
// projects) into warm workspace cards.
|
|
13
|
+
import { reskinDocument, rkEscape } from "./document.js";
|
|
14
|
+
const SAMPLE = {
|
|
15
|
+
agencyName: "Sunset Social",
|
|
16
|
+
ownerEmail: "hey@sunsetsocial.co",
|
|
17
|
+
stats: { clients: 7, activeProjects: 34, seatsUsed: 12, seatsTotal: 20, monthlySpendUsd: 486.2 },
|
|
18
|
+
clients: [
|
|
19
|
+
{ name: "Meadow Coffee Co.", email: "ops@meadowcoffee.com", userId: "usr_4b12a9", plan: "Pro", status: "active", tint: "gold", projects: 9, scheduled: 6, walletUsd: 42.8, seats: 3, lastSeen: "2h ago" },
|
|
20
|
+
{ name: "Northwind Outdoors", email: "team@northwind.gear", userId: "usr_9f0c31", plan: "Pro", status: "active", tint: "green", projects: 7, scheduled: 4, walletUsd: 88.15, seats: 2, lastSeen: "5h ago" },
|
|
21
|
+
{ name: "Lumen Skincare", email: "grow@lumenskin.co", userId: "usr_2d77e4", plan: "Growth", status: "active", tint: "violet", projects: 6, scheduled: 8, walletUsd: 24.0, seats: 2, lastSeen: "yesterday" },
|
|
22
|
+
{ name: "Harborline Fitness", email: "studio@harborline.fit", userId: "usr_71aa08", plan: "Starter", status: "trial", tint: "sky", projects: 4, scheduled: 2, walletUsd: 5.0, seats: 1, lastSeen: "3d ago" },
|
|
23
|
+
{ name: "Cedar & Co. Home", email: "hello@cedarandco.com", userId: "usr_58bb1c", plan: "Pro", status: "active", tint: "coral", projects: 5, scheduled: 3, walletUsd: 61.4, seats: 2, lastSeen: "6h ago" },
|
|
24
|
+
{ name: "Beacon Legal", email: "marketing@beaconlegal.io", userId: "usr_3c90f7", plan: "Growth", status: "paused", tint: "ink", projects: 2, scheduled: 0, walletUsd: 12.9, seats: 1, lastSeen: "2w ago" },
|
|
25
|
+
{ name: "Pixel Pantry", email: "founders@pixelpantry.app", userId: "usr_6e14d0", plan: "Starter", status: "trial", tint: "sky", projects: 1, scheduled: 1, walletUsd: 5.0, seats: 1, lastSeen: "1d ago" }
|
|
26
|
+
]
|
|
27
|
+
};
|
|
28
|
+
const STATUS_PILL = {
|
|
29
|
+
active: { cls: "rk-pill-green", label: "Active", dot: "●" },
|
|
30
|
+
trial: { cls: "rk-pill-sky", label: "Trial", dot: "◐" },
|
|
31
|
+
paused: { cls: "rk-pill-red", label: "Paused", dot: "◌" }
|
|
32
|
+
};
|
|
33
|
+
function money(n) {
|
|
34
|
+
const frac = Number.isInteger(n) ? 0 : 2;
|
|
35
|
+
return `$${n.toLocaleString("en-US", { minimumFractionDigits: frac, maximumFractionDigits: 2 })}`;
|
|
36
|
+
}
|
|
37
|
+
function initials(name) {
|
|
38
|
+
const letters = name
|
|
39
|
+
.replace(/&/g, " ")
|
|
40
|
+
.split(/\s+/)
|
|
41
|
+
.map((w) => (w.match(/[a-z0-9]/i) || [""])[0])
|
|
42
|
+
.filter(Boolean);
|
|
43
|
+
const out = `${letters[0] || ""}${letters[1] || ""}`;
|
|
44
|
+
return (out || "?").toUpperCase();
|
|
45
|
+
}
|
|
46
|
+
function statTile(label, value, sub) {
|
|
47
|
+
return `<div class="rk-card rk-card-pad-sm rk-agency-stat">
|
|
48
|
+
<div class="rk-stat-label">${rkEscape(label)}</div>
|
|
49
|
+
<div class="rk-stat-num">${value}</div>
|
|
50
|
+
<div class="rk-agency-stat-sub">${rkEscape(sub)}</div>
|
|
51
|
+
</div>`;
|
|
52
|
+
}
|
|
53
|
+
function clientCard(c) {
|
|
54
|
+
const pill = STATUS_PILL[c.status];
|
|
55
|
+
const search = `${c.name} ${c.email} ${c.userId}`.toLowerCase();
|
|
56
|
+
return `<article class="rk-card rk-card-hover rk-agency-card" data-rk-client="${rkEscape(search)}">
|
|
57
|
+
<div class="rk-agency-card-top">
|
|
58
|
+
<span class="rk-tile rk-tile-lg rk-tile-${c.tint} rk-agency-avatar">${rkEscape(initials(c.name))}</span>
|
|
59
|
+
<div class="rk-agency-card-id">
|
|
60
|
+
<h3 class="rk-agency-name">${rkEscape(c.name)}</h3>
|
|
61
|
+
<div class="rk-agency-email" role="button" tabindex="0" data-rk-copy-text="${rkEscape(c.email)}" title="Copy email">${rkEscape(c.email)}</div>
|
|
62
|
+
</div>
|
|
63
|
+
<span class="rk-pill ${pill.cls} rk-agency-status">${pill.dot} ${pill.label}</span>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="rk-agency-meta">
|
|
66
|
+
<span class="rk-pill rk-pill-gold">${rkEscape(c.plan)} plan</span>
|
|
67
|
+
<span class="rk-pill">${c.seats} ${c.seats === 1 ? "seat" : "seats"}</span>
|
|
68
|
+
<span class="rk-agency-user" role="button" tabindex="0" data-rk-copy-text="${rkEscape(c.userId)}" title="Copy user id">${rkEscape(c.userId)}</span>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="rk-agency-minis">
|
|
71
|
+
<div class="rk-agency-mini">
|
|
72
|
+
<div class="rk-agency-mini-num">${c.projects}</div>
|
|
73
|
+
<div class="rk-agency-mini-lbl">Projects</div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="rk-agency-mini">
|
|
76
|
+
<div class="rk-agency-mini-num">${c.scheduled}</div>
|
|
77
|
+
<div class="rk-agency-mini-lbl">Scheduled</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="rk-agency-mini">
|
|
80
|
+
<div class="rk-agency-mini-num">${money(c.walletUsd)}</div>
|
|
81
|
+
<div class="rk-agency-mini-lbl">Wallet</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="rk-agency-card-foot">
|
|
85
|
+
<a class="rk-btn rk-btn-ghost rk-btn-sm rk-agency-open" href="/u/${encodeURIComponent(c.userId)}/library" target="_blank" rel="noreferrer">Open workspace <span class="rk-arrow">→</span></a>
|
|
86
|
+
<button class="rk-btn rk-btn-ghost rk-btn-sm" type="button" data-rk-copy-login="/auto-login?client=${encodeURIComponent(c.userId)}">Copy login</button>
|
|
87
|
+
<span class="rk-agency-seen">Active ${rkEscape(c.lastSeen)}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</article>`;
|
|
90
|
+
}
|
|
91
|
+
export function renderReskinAgency() {
|
|
92
|
+
const a = SAMPLE;
|
|
93
|
+
const s = a.stats;
|
|
94
|
+
const stats = `
|
|
95
|
+
<div class="rk-grid rk-grid-4 rk-agency-stats">
|
|
96
|
+
${statTile("Client workspaces", String(s.clients), "all sub-accounts you manage")}
|
|
97
|
+
${statTile("Active projects", String(s.activeProjects), "across every client")}
|
|
98
|
+
${statTile("Seats used", `${s.seatsUsed}<span class="rk-agency-stat-of"> / ${s.seatsTotal}</span>`, "team members with access")}
|
|
99
|
+
${statTile("Monthly spend", money(s.monthlySpendUsd), "pooled render + clip usage")}
|
|
100
|
+
</div>`;
|
|
101
|
+
const clientCards = a.clients.map(clientCard).join("");
|
|
102
|
+
const inviteCard = `<article class="rk-card rk-agency-invite" data-rk-client="__invite__">
|
|
103
|
+
<span class="rk-tile rk-tile-lg rk-tile-gold rk-agency-invite-ic">+</span>
|
|
104
|
+
<h3 class="rk-agency-invite-title">Add a client workspace</h3>
|
|
105
|
+
<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>
|
|
106
|
+
<button class="rk-btn rk-btn-gold" type="button" data-rk-add-client>Add client <span class="rk-arrow">→</span></button>
|
|
107
|
+
</article>`;
|
|
108
|
+
const body = `
|
|
109
|
+
<main class="rk-container rk-page">
|
|
110
|
+
<div class="rk-page-head rk-agency-head">
|
|
111
|
+
<div class="rk-agency-head-copy">
|
|
112
|
+
<span class="rk-badge">Agency workspace</span>
|
|
113
|
+
<h1 class="rk-h1">Agency</h1>
|
|
114
|
+
<p class="rk-sub">Manage every client workspace from one seat — ${a.clients.length} accounts, shared seats, and one pooled bill.</p>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="rk-agency-head-cta">
|
|
117
|
+
<a class="rk-btn rk-btn-ghost" href="/agency-client-login" target="_blank" rel="noreferrer">Add login</a>
|
|
118
|
+
<button class="rk-btn rk-btn-gold rk-btn-lg" type="button" data-rk-add-client>Add client <span class="rk-arrow">→</span></button>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
${stats}
|
|
123
|
+
|
|
124
|
+
<section class="rk-agency-panel">
|
|
125
|
+
<div class="rk-spread rk-wrap rk-agency-toolbar">
|
|
126
|
+
<div class="rk-agency-toolbar-copy">
|
|
127
|
+
<span class="rk-eyebrow">Clients</span>
|
|
128
|
+
<h2 class="rk-agency-h2">${a.clients.length} client workspaces</h2>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="rk-agency-search-wrap">
|
|
131
|
+
<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>
|
|
132
|
+
<input class="rk-input rk-agency-search" type="search" placeholder="Search name, email, or user id" autocomplete="off" spellcheck="false" data-rk-agency-search />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div class="rk-grid rk-grid-3 rk-agency-grid" data-rk-agency-grid>
|
|
136
|
+
${clientCards}
|
|
137
|
+
${inviteCard}
|
|
138
|
+
</div>
|
|
139
|
+
<div class="rk-agency-empty" data-rk-agency-empty hidden>
|
|
140
|
+
<p class="rk-muted">No client workspaces match that search.</p>
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
</main>`;
|
|
144
|
+
const pageCss = `
|
|
145
|
+
.rk-agency-head{grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:20px}
|
|
146
|
+
.rk-agency-head-copy{display:grid;gap:8px;min-width:0}
|
|
147
|
+
.rk-agency-head-copy .rk-badge{justify-self:start}
|
|
148
|
+
.rk-agency-head-cta{display:flex;align-items:center;gap:10px;flex:none}
|
|
149
|
+
@media(max-width:760px){.rk-agency-head{grid-template-columns:1fr}.rk-agency-head-cta{flex-wrap:wrap}}
|
|
150
|
+
|
|
151
|
+
.rk-agency-stats{margin-bottom:28px}
|
|
152
|
+
.rk-agency-stat{display:grid;gap:6px;align-content:start}
|
|
153
|
+
.rk-agency-stat-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:8px;background:var(--rk-gold-tint);font-size:13px}
|
|
154
|
+
.rk-agency-stat-sub{font-size:12px;color:var(--rk-text-faint)}
|
|
155
|
+
.rk-agency-stat-of{font-size:var(--rk-text-xl);color:var(--rk-text-faint);font-weight:700}
|
|
156
|
+
|
|
157
|
+
.rk-agency-panel{background:var(--rk-bg-section);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);padding:24px}
|
|
158
|
+
.rk-agency-toolbar{margin-bottom:20px}
|
|
159
|
+
.rk-agency-toolbar-copy{display:grid;gap:4px}
|
|
160
|
+
.rk-agency-h2{font-size:var(--rk-text-2xl);letter-spacing:-.02em;color:var(--rk-ink)}
|
|
161
|
+
.rk-agency-search-wrap{position:relative;width:min(360px,100%)}
|
|
162
|
+
.rk-agency-search-ic{position:absolute;left:15px;top:50%;transform:translateY(-50%);font-size:14px;opacity:.6;pointer-events:none}
|
|
163
|
+
.rk-agency-search{padding-left:40px;background:#fff;border-radius:var(--rk-r-full)}
|
|
164
|
+
@media(max-width:620px){.rk-agency-search-wrap{width:100%}}
|
|
165
|
+
|
|
166
|
+
.rk-agency-grid{align-items:stretch}
|
|
167
|
+
.rk-agency-card{padding:22px;display:flex;flex-direction:column;gap:16px}
|
|
168
|
+
.rk-agency-card-top{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:12px;align-items:start}
|
|
169
|
+
.rk-agency-avatar{font-family:var(--rk-font-display);font-weight:800;font-size:22px}
|
|
170
|
+
.rk-agency-card-id{min-width:0;display:grid;gap:2px}
|
|
171
|
+
.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}
|
|
172
|
+
.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)}
|
|
173
|
+
.rk-agency-email:hover{color:var(--rk-gold-700)}
|
|
174
|
+
.rk-agency-status{flex:none;align-self:start;white-space:nowrap}
|
|
175
|
+
.rk-agency-meta{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
|
|
176
|
+
.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)}
|
|
177
|
+
.rk-agency-user:hover{color:var(--rk-n-600)}
|
|
178
|
+
.rk-agency-minis{display:grid;grid-template-columns:repeat(3,1fr);gap:2px;background:var(--rk-n-100);border-radius:var(--rk-r-xl);padding:14px 10px;margin-top:auto}
|
|
179
|
+
.rk-agency-mini{display:grid;gap:3px;justify-items:center;text-align:center;position:relative}
|
|
180
|
+
.rk-agency-mini+.rk-agency-mini::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);height:26px;width:1px;background:var(--rk-border)}
|
|
181
|
+
.rk-agency-mini-num{font-family:var(--rk-font-display);font-weight:800;font-size:1.1rem;color:var(--rk-ink);letter-spacing:-.02em}
|
|
182
|
+
.rk-agency-mini-lbl{font-size:11px;font-weight:600;color:var(--rk-text-muted);text-transform:uppercase;letter-spacing:.03em}
|
|
183
|
+
.rk-agency-card-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
|
|
184
|
+
.rk-agency-open{font-weight:600}
|
|
185
|
+
.rk-agency-seen{margin-left:auto;font-size:12px;color:var(--rk-text-faint);white-space:nowrap}
|
|
186
|
+
|
|
187
|
+
.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}
|
|
188
|
+
.rk-agency-invite-ic{font-size:26px}
|
|
189
|
+
.rk-agency-invite-title{font-size:var(--rk-text-lg);font-family:var(--rk-font-display);font-weight:800;color:var(--rk-ink)}
|
|
190
|
+
.rk-agency-invite-copy{font-size:13px;max-width:26ch}
|
|
191
|
+
|
|
192
|
+
.rk-agency-empty{display:none;flex-direction:column;align-items:center;gap:12px;padding:56px 20px;text-align:center}
|
|
193
|
+
.rk-agency-empty[data-rk-shown="1"]{display:flex}
|
|
194
|
+
`;
|
|
195
|
+
const script = `
|
|
196
|
+
(function(){
|
|
197
|
+
var root=document;
|
|
198
|
+
var grid=root.querySelector('[data-rk-agency-grid]');
|
|
199
|
+
var search=root.querySelector('[data-rk-agency-search]');
|
|
200
|
+
var empty=root.querySelector('[data-rk-agency-empty]');
|
|
201
|
+
// client-side search filter over the workspace cards
|
|
202
|
+
function filter(){
|
|
203
|
+
if(!grid)return;
|
|
204
|
+
var q=(search&&search.value||'').trim().toLowerCase();
|
|
205
|
+
var visible=0;
|
|
206
|
+
grid.querySelectorAll('[data-rk-client]').forEach(function(card){
|
|
207
|
+
var key=card.getAttribute('data-rk-client')||'';
|
|
208
|
+
var isInvite=key==='__invite__';
|
|
209
|
+
var hit=!q||(!isInvite&&key.indexOf(q)!==-1);
|
|
210
|
+
// keep the invite card visible only when not searching
|
|
211
|
+
var show=isInvite?!q:hit;
|
|
212
|
+
card.hidden=!show;
|
|
213
|
+
if(show&&!isInvite)visible++;
|
|
214
|
+
});
|
|
215
|
+
if(empty){empty.hidden=!(q&&visible===0);empty.setAttribute('data-rk-shown',(q&&visible===0)?'1':'0');}
|
|
216
|
+
}
|
|
217
|
+
if(search)search.addEventListener('input',filter);
|
|
218
|
+
// copy helpers (email / user id / login link)
|
|
219
|
+
function flash(el,label){
|
|
220
|
+
var tag=el.getAttribute('data-rk-flash-tag')||el.getAttribute('title')||'';
|
|
221
|
+
var prev=el.textContent;
|
|
222
|
+
el.textContent=label;
|
|
223
|
+
setTimeout(function(){el.textContent=prev;},1200);
|
|
224
|
+
}
|
|
225
|
+
function copy(value,el){
|
|
226
|
+
if(!value||!navigator.clipboard)return;
|
|
227
|
+
navigator.clipboard.writeText(value).then(function(){flash(el,'Copied!');});
|
|
228
|
+
}
|
|
229
|
+
root.querySelectorAll('[data-rk-copy-text]').forEach(function(el){
|
|
230
|
+
var run=function(){copy(el.getAttribute('data-rk-copy-text'),el);};
|
|
231
|
+
el.addEventListener('click',run);
|
|
232
|
+
el.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();run();}});
|
|
233
|
+
});
|
|
234
|
+
root.querySelectorAll('[data-rk-copy-login]').forEach(function(btn){
|
|
235
|
+
btn.addEventListener('click',function(){
|
|
236
|
+
var rel=btn.getAttribute('data-rk-copy-login')||'';
|
|
237
|
+
var abs=rel;try{abs=new URL(rel,location.origin).toString();}catch(_){}
|
|
238
|
+
copy(abs,btn);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
// "Add client" CTA — reskin surface has no backend; nudge to real flow
|
|
242
|
+
root.querySelectorAll('[data-rk-add-client]').forEach(function(btn){
|
|
243
|
+
btn.addEventListener('click',function(){window.open('/agency-client-login','_blank','noopener,noreferrer');});
|
|
244
|
+
});
|
|
245
|
+
})();`;
|
|
246
|
+
return reskinDocument({
|
|
247
|
+
title: "vidfarm reskin — Agency",
|
|
248
|
+
description: "Reskinned vidfarm agency page (client workspaces, seats, pooled billing).",
|
|
249
|
+
activeSlug: "agency",
|
|
250
|
+
pageCss,
|
|
251
|
+
body,
|
|
252
|
+
script
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=agency-page.js.map
|