feat: social video uploads + hero page video + TikTok content

Hero page:
- Replace GIF with squaremcp-hero-loop.mp4 (autoplay, muted, loop)
- Update styles, scripts, tests, Dockerfile, baselines
- Deployed and verified

Social video uploads:
- Twitter/X: uploadVideoAndTweet via v1.1 media/upload + v2 tweets
- Facebook: createVideoPost via Graph API /{pageId}/videos
- Instagram: createReel via Graph API (container → poll → publish)
- TikTok: REST endpoints + OpenAPI schema for video upload

Marketing:
- TikTok content prompts, scripts, and posting schedule

Note: Remotion not mentioned in any user-facing content
This commit is contained in:
Garfield
2026-05-11 13:55:58 -04:00
parent de9d74bb2b
commit ecdf332b78
17 changed files with 854 additions and 54 deletions

View File

@@ -158,3 +158,31 @@ export async function createPhotoPost(
throw err;
}
}
export async function createVideoPost(
args: { video_url: string; description?: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; video_id: string; post_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'facebook:createVideoPost') : null;
const auditArgs = { video_url: args.video_url };
const { accessToken, pageId } = await resolveCreds(args, customer);
try {
const body: Record<string, unknown> = {
file_url: args.video_url,
};
if (args.description) body.description = args.description;
const data = await fbRequest(`/${pageId}/videos`, accessToken, 'POST', body);
const result = {
success: true,
video_id: String(data.id ?? ''),
post_id: String(data.post_id ?? data.id ?? ''),
};
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}

View File

@@ -116,11 +116,11 @@ export async function getMedia(
}));
}
export async function createPost(
export async function createImagePost(
args: { image_url: string; caption?: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; media_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'instagram:createPost') : null;
const audit = customer ? createToolAudit(customer.id, 'instagram:createImagePost') : null;
const auditArgs = { image_url: args.image_url };
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
@@ -129,7 +129,7 @@ export async function createPost(
`/${businessAccountId}/media`,
accessToken,
'POST',
{ image_url: args.image_url, caption: args.caption, media_type: 'REELS' }
{ image_url: args.image_url, caption: args.caption }
);
const creationId = container.id;
@@ -150,3 +150,65 @@ export async function createPost(
throw err;
}
}
export async function createReel(
args: { video_url: string; caption?: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; media_id: string }> {
const audit = customer ? createToolAudit(customer.id, 'instagram:createReel') : null;
const auditArgs = { video_url: args.video_url };
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
try {
const container = await instagramRequest(
`/${businessAccountId}/media`,
accessToken,
'POST',
{ media_type: 'REELS', video_url: args.video_url, caption: args.caption, share_to_feed: true }
);
const creationId = container.id;
if (!creationId) throw new Error('Failed to create Instagram media container');
// Poll for container status
let retries = 0;
const maxRetries = 10;
const delayMs = 5000;
while (retries < maxRetries) {
const statusData = await instagramRequest(
`/${creationId}?fields=status_code`,
accessToken
);
if (statusData.status_code === 'FINISHED') {
break;
}
if (statusData.status_code === 'ERROR') {
throw new Error('Instagram media container processing failed');
}
retries++;
if (retries >= maxRetries) {
throw new Error('Instagram media container polling timed out');
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
const publish = await instagramRequest(
`/${businessAccountId}/media_publish`,
accessToken,
'POST',
{ creation_id: creationId }
);
const result = { success: true, media_id: publish.id };
if (audit) await audit.success(auditArgs);
return result;
} catch (err) {
if (audit) await audit.error(auditArgs, String(err));
throw err;
}
}

View File

@@ -2,6 +2,7 @@ import type { Customer } from '../billing/middleware.js';
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
const TWITTER_API_BASE = 'https://api.twitter.com/2';
const TWITTER_UPLOAD_BASE = 'https://upload.twitter.com/1.1';
function getEnvToken(account: string): string {
const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`;
@@ -118,3 +119,154 @@ export async function createTweet(
'Upgrade to Basic ($100/month) or Pro ($5,000/month) at https://developer.twitter.com/en/portal/products'
);
}
export async function uploadVideoAndTweet(
args: { videoUrl: string; text: string; account?: string },
customer?: Customer
): Promise<{ success: boolean; tweet_id: string; url: string }> {
const bearerToken = await resolveToken(args, customer);
// 1. Download video
const videoRes = await fetch(args.videoUrl, { signal: AbortSignal.timeout(60000) });
if (!videoRes.ok) {
throw new Error(`Failed to download video: ${videoRes.status} ${videoRes.statusText}`);
}
const videoBuffer = Buffer.from(await videoRes.arrayBuffer());
const totalBytes = videoBuffer.length;
// 2. INIT
const initUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
initUrl.searchParams.set('command', 'INIT');
initUrl.searchParams.set('total_bytes', String(totalBytes));
initUrl.searchParams.set('media_type', 'video/mp4');
const initRes = await fetch(initUrl.toString(), {
method: 'POST',
headers: { 'Authorization': `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(15000),
});
if (!initRes.ok) {
const err = await initRes.text();
throw new Error(`Twitter media INIT error (${initRes.status}): ${err}`);
}
const initData = (await initRes.json()) as { media_id_string?: string };
const mediaId = initData.media_id_string;
if (!mediaId) throw new Error('Twitter media INIT did not return media_id_string');
// 3. APPEND chunks
const CHUNK_SIZE = 2 * 1024 * 1024;
let segmentIndex = 0;
for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
const chunk = videoBuffer.subarray(offset, offset + CHUNK_SIZE);
const formData = new FormData();
formData.append('media', new Blob([chunk]));
const appendUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
appendUrl.searchParams.set('command', 'APPEND');
appendUrl.searchParams.set('media_id', mediaId);
appendUrl.searchParams.set('segment_index', String(segmentIndex));
const appendRes = await fetch(appendUrl.toString(), {
method: 'POST',
headers: { 'Authorization': `Bearer ${bearerToken}` },
body: formData,
signal: AbortSignal.timeout(30000),
});
if (!appendRes.ok) {
const err = await appendRes.text();
throw new Error(`Twitter media APPEND error (${appendRes.status}): ${err}`);
}
segmentIndex++;
}
// 4. FINALIZE
const finalizeUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
finalizeUrl.searchParams.set('command', 'FINALIZE');
finalizeUrl.searchParams.set('media_id', mediaId);
const finalizeRes = await fetch(finalizeUrl.toString(), {
method: 'POST',
headers: { 'Authorization': `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(15000),
});
if (!finalizeRes.ok) {
const err = await finalizeRes.text();
throw new Error(`Twitter media FINALIZE error (${finalizeRes.status}): ${err}`);
}
const finalizeData = (await finalizeRes.json()) as {
processing_info?: { state?: string; check_after_secs?: number; error?: { message?: string } };
};
// 5. STATUS polling
let processingInfo = finalizeData.processing_info;
while (processingInfo && processingInfo.state !== 'succeeded') {
if (processingInfo.state === 'failed') {
const errMsg = processingInfo.error?.message ?? 'Unknown processing error';
throw new Error(`Twitter media processing failed: ${errMsg}`);
}
const waitSeconds = processingInfo.check_after_secs ?? 5;
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
const statusUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
statusUrl.searchParams.set('command', 'STATUS');
statusUrl.searchParams.set('media_id', mediaId);
const statusRes = await fetch(statusUrl.toString(), {
method: 'GET',
headers: { 'Authorization': `Bearer ${bearerToken}` },
signal: AbortSignal.timeout(15000),
});
if (!statusRes.ok) {
const err = await statusRes.text();
throw new Error(`Twitter media STATUS error (${statusRes.status}): ${err}`);
}
const statusData = (await statusRes.json()) as {
processing_info?: { state?: string; check_after_secs?: number; error?: { message?: string } };
};
processingInfo = statusData.processing_info;
if (processingInfo?.state === 'failed') {
const errMsg = processingInfo.error?.message ?? 'Unknown processing error';
throw new Error(`Twitter media processing failed: ${errMsg}`);
}
}
// 6. Post tweet with video
const tweetRes = await fetch(`${TWITTER_API_BASE}/tweets`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: args.text,
media: { media_keys: [mediaId] },
}),
signal: AbortSignal.timeout(15000),
});
if (!tweetRes.ok) {
const err = await tweetRes.text();
throw new Error(`Twitter tweet error (${tweetRes.status}): ${err}`);
}
const tweetData = (await tweetRes.json()) as { data?: { id?: string } };
const tweetId = tweetData.data?.id;
if (!tweetId) throw new Error('Twitter tweet creation did not return an ID');
return {
success: true,
tweet_id: tweetId,
url: `https://twitter.com/i/web/status/${tweetId}`,
};
}