diff --git a/.env.example b/.env.example index f1baf5a..75ff28e 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,12 @@ # GROK_WEB_DEBUG=0 # GROK_IMAGE_OUT_DIR= -# Output directory override (optional) -# OUTPUT_DIR= +# Shared output root (default: ~/ii/content-engine/) +# All repos in the ii ecosystem read/write content here. +# II_ROOT=~/ii/content-engine + +# Post outro clip path (default: II_ROOT/assets/plug.mov) +# CONTENT_ENGINE_POST_OUTRO_PATH= # Python binary override (optional, default: python3) # CONTENT_GEN_PYTHON=python3 diff --git a/AGENTS.md b/AGENTS.md index ad3686a..7b2f0bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,7 @@ Before authoring prompts, assets, or render jobs: - Before declaring a video run blocked on missing `XAI_API_KEY`, check for browser-session fallbacks such as `auth/grok-session-cookies.json`, `auth/grok-storage-state.json`, or `cookies/x_cookies.json`. - Browser fallback is only valid if the Grok submit path starts a real generation job. If browser submit opens the `SuperGrok` subscribe modal instead, treat the run as browser-blocked and do not scrape Discover/gallery media as a fake result. - Caption nudge: carousel captions and reel/video captions should usually land between 2100 and 2200 characters, front-load the hook in the first 125 characters, stay SEO-friendly, and use the local caption-writing fallback rather than treating missing `XAI_API_KEY` as a caption blocker. -- Posted-video default: queued videos should stay raw in `output/scheduled_videos/`, the standard promo clip from `/Users/admin/Documents/plug.mov` should be appended immediately before each platform post attempt, and the caption opener should be `Make videos like this by searching ii-content-engine on GitHub.` +- Posted-video default: queued videos should stay raw in `scheduled_videos/` under `II_ROOT`, the standard promo clip from `II_ROOT/assets/plug.mov` should be appended immediately before each platform post attempt, and the caption opener should be `Make videos like this by searching ii-content-engine on GitHub.` - For stitched multi-clip videos, continuity is a primary quality requirement, not a nice-to-have. Agents should optimize for a coherent script, stable character/world design, and visual consistency across clips before optimizing for novelty. - If continuity matters across clips, prefer image-conditioned generation and reference-image reuse whenever the model or pipeline supports it. Do not rely on fresh text-only reinterpretation of the same character, environment, object, or world style across clips unless there is no better option. - For continuity-sensitive character stories, the required default asset chain is: diff --git a/CLAUDE.md b/CLAUDE.md index 69c79ad..2d7b05c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,12 @@ +# BRANCH POLICY — READ FIRST + +**This repository MUST always be checked out on the `dev` branch. No exceptions.** + +- Never switch this working copy to `main`, a feature branch, or detached HEAD. All work happens on `dev`. +- If `git submodule update`, a merge, or any tool moves something off `dev`, switch it back to `dev` immediately before continuing. + +--- + # CLAUDE.md ## Repo Role @@ -94,7 +103,7 @@ Before authoring prompts, assets, or render jobs: - Before treating a video render as blocked, check for browser-session fallback files such as `auth/grok-session-cookies.json`, `auth/grok-storage-state.json`, or `cookies/x_cookies.json`. - Browser fallback is only valid if the Grok submit path starts a real generation job. If browser submit opens the `SuperGrok` subscribe modal instead, treat the run as browser-blocked and do not scrape Discover/gallery media as a fake result. - Caption nudge: carousel captions and reel/video captions should usually land between 2100 and 2200 characters, front-load the hook in the first 125 characters, stay SEO-friendly, and use the local caption-writing fallback instead of treating missing `XAI_API_KEY` as a caption blocker. -- Posted-video default: queued videos should stay raw in `output/scheduled_videos/`, the standard promo clip from `/Users/admin/Documents/plug.mov` should be appended immediately before each platform post attempt, and the caption opener should be `Make videos like this by searching ii-content-engine on GitHub.` +- Posted-video default: queued videos should stay raw in `scheduled_videos/` under `II_ROOT`, the standard promo clip from `II_ROOT/assets/plug.mov` should be appended immediately before each platform post attempt, and the caption opener should be `Make videos like this by searching ii-content-engine on GitHub.` - For stitched multi-clip videos, treat continuity as a first-order requirement: the script, acting beats, character design, wardrobe, environment, and cinematic style should survive across clips unless the format explicitly calls for change. - If continuity matters across clips, prefer image-conditioned generation and reference-image reuse whenever the toolchain supports it. Do not let each clip reinvent the same character, environment, object, or world style from scratch if that can be avoided. - For continuity-sensitive character stories, the required default asset chain is: diff --git a/Dockerfile.autopost b/Dockerfile.autopost index 77be699..a0a46fb 100644 --- a/Dockerfile.autopost +++ b/Dockerfile.autopost @@ -6,6 +6,7 @@ ENV CONTENT_GEN_DISABLE_BROWSER_SANDBOX=1 RUN apt-get update && apt-get install -y --no-install-recommends \ chromium \ + ffmpeg \ python3 \ python3-pip \ ca-certificates \ diff --git a/README.md b/README.md index 50b363e..05ea37f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ Example output: [`example/fruit-revenge-arc-api-portrait/fruit-revenge-arc-api-portrait.mp4`](example/fruit-revenge-arc-api-portrait/fruit-revenge-arc-api-portrait.mp4) +Click the preview to open the full MP4. + This is the current reference example for the video pipeline. It was generated from saved repo state, not a one-off prompt, and it follows the intended chain: `hero portrait -> reference sheet -> portrait scene-start frame -> clip -> stitched final` diff --git a/code/carousel/generate-slideshow.js b/code/carousel/generate-slideshow.js index db6781d..415a65e 100644 --- a/code/carousel/generate-slideshow.js +++ b/code/carousel/generate-slideshow.js @@ -335,9 +335,7 @@ async function finalizeSlides(processedSlides) { * Save images and metadata locally */ function saveOutput(slides, content, options = {}) { - const outputRoot = process.env.OUTPUT_DIR - ? path.resolve(process.env.OUTPUT_DIR) - : CAROUSELS_DIR; + const outputRoot = CAROUSELS_DIR; const folderName = safeOutputName(options.outputName || options.templateId || content.topic, 'carousel'); const folder = path.join(outputRoot, folderName); diff --git a/code/core/paths.js b/code/core/paths.js index 5a1e483..f17b5cc 100644 --- a/code/core/paths.js +++ b/code/core/paths.js @@ -1,14 +1,29 @@ +import fs from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +function resolveIIRoot() { + const env = process.env.II_ROOT; + if (env) { + const expanded = env.startsWith('~') ? path.join(os.homedir(), env.slice(1)) : env; + return path.resolve(expanded); + } + return path.join(os.homedir(), 'ii', 'content-engine'); +} + export const ROOT_DIR = path.join(__dirname, '..', '..'); export const DOCS_DIR = path.join(ROOT_DIR, 'docs'); -export const PROMPTS_DIR = path.join(ROOT_DIR, 'prompts'); -export const AUTH_DIR = path.join(ROOT_DIR, 'auth'); -export const OUTPUT_DIR = path.join(ROOT_DIR, 'output'); +export const II_ROOT = resolveIIRoot(); +export const OUTPUT_DIR = II_ROOT; +export const AUTH_DIR = path.join(II_ROOT, 'auth'); +export const COOKIES_DIR = path.join(II_ROOT, 'cookies'); +export const PROMPTS_DIR = path.join(II_ROOT, 'prompts'); +export const RESEARCH_DIR = path.join(II_ROOT, 'research'); +export const DOWNLOADS_DIR = path.join(II_ROOT, 'downloads'); export const VIDEOS_DIR = path.join(OUTPUT_DIR, 'videos'); export const CAROUSELS_DIR = path.join(OUTPUT_DIR, 'carousels'); export const SCHEDULED_VIDEOS_DIR = path.join(OUTPUT_DIR, 'scheduled_videos'); @@ -16,6 +31,9 @@ export const SCHEDULED_CAROUSELS_DIR = path.join(OUTPUT_DIR, 'scheduled_carousel export const POSTED_VIDEOS_DIR = path.join(OUTPUT_DIR, 'posted_videos'); export const TEMP_DIR = path.join(OUTPUT_DIR, 'tmp'); +// Ensure the shared root directories exist +fs.mkdirSync(II_ROOT, { recursive: true }); + export const VIDEO_TEMPLATE_REGISTRY_PATH = path.join(PROMPTS_DIR, 'video-templates.json'); export const CAROUSEL_TEMPLATE_REGISTRY_PATH = path.join(PROMPTS_DIR, 'carousel-templates.json'); diff --git a/code/crop-for-instagram.js b/code/crop-for-instagram.js index 7824d19..b0fb345 100644 --- a/code/crop-for-instagram.js +++ b/code/crop-for-instagram.js @@ -17,7 +17,7 @@ const TIKTOK_HEIGHT = 1920; const INSTAGRAM_WIDTH = 1080; const INSTAGRAM_HEIGHT = 1350; -// Height to crop +// Height to crop from the canonical 9:16 source const CROP_AMOUNT = TIKTOK_HEIGHT - INSTAGRAM_HEIGHT; // 570px // Original text Y positions (from add-text-overlay.js SAFE_ZONES) @@ -38,8 +38,10 @@ const RULE_OF_THIRDS = { * @param {string} textPosition - 'top', 'center', or 'bottom-safe' * @returns {object} - { top, left, width, height } for sharp extract */ -function calculateCrop(textPosition) { - const originalY = TEXT_POSITIONS[textPosition] || TEXT_POSITIONS.top; +function calculateCrop(textPosition, sourceHeight = TIKTOK_HEIGHT) { + const cropAmount = Math.max(0, sourceHeight - INSTAGRAM_HEIGHT); + const scale = sourceHeight / TIKTOK_HEIGHT; + const originalY = (TEXT_POSITIONS[textPosition] || TEXT_POSITIONS.top) * scale; // Determine which rule of thirds line to target let targetY; @@ -61,16 +63,26 @@ function calculateCrop(textPosition) { } // Clamp topCrop to valid range [0, CROP_AMOUNT] - topCrop = Math.max(0, Math.min(CROP_AMOUNT, topCrop)); + topCrop = Math.max(0, Math.min(cropAmount, topCrop)); return { left: 0, - top: topCrop, + top: Math.round(topCrop), width: INSTAGRAM_WIDTH, height: INSTAGRAM_HEIGHT }; } +async function copyImage(inputPath, outputPath) { + fs.copyFileSync(inputPath, outputPath); + return { + left: 0, + top: 0, + width: INSTAGRAM_WIDTH, + height: INSTAGRAM_HEIGHT, + }; +} + /** * Crop a single image for Instagram * @param {string} inputPath - Path to original image @@ -78,9 +90,34 @@ function calculateCrop(textPosition) { * @param {string} textPosition - Text position from metadata */ async function cropImage(inputPath, outputPath, textPosition) { - const crop = calculateCrop(textPosition); + const image = sharp(inputPath); + const metadata = await image.metadata(); + const sourceWidth = Number(metadata.width) || INSTAGRAM_WIDTH; + const sourceHeight = Number(metadata.height) || INSTAGRAM_HEIGHT; + + if (sourceWidth === INSTAGRAM_WIDTH && sourceHeight === INSTAGRAM_HEIGHT) { + return copyImage(inputPath, outputPath); + } - await sharp(inputPath) + if (sourceHeight <= INSTAGRAM_HEIGHT) { + await image + .resize(INSTAGRAM_WIDTH, INSTAGRAM_HEIGHT, { + fit: 'cover', + position: 'centre', + }) + .toFile(outputPath); + + return { + left: 0, + top: 0, + width: INSTAGRAM_WIDTH, + height: INSTAGRAM_HEIGHT, + }; + } + + const crop = calculateCrop(textPosition, sourceHeight); + + await image .extract(crop) .toFile(outputPath); @@ -106,10 +143,17 @@ async function processSlideshow(folderPath) { if (!fs.existsSync(instagramFolder)) { fs.mkdirSync(instagramFolder, { recursive: true }); } + for (const file of fs.readdirSync(instagramFolder)) { + if (/^slide_\d+\.(jpg|png)$/i.test(file) || file === 'metadata.json') { + fs.rmSync(path.join(instagramFolder, file), { force: true }); + } + } console.log(`\nProcessing: ${metadata.topic}`); console.log(`Output: ${instagramFolder}`); + const cropBySlide = new Map(); + // Process each slide for (const slide of metadata.slides) { const slideNum = slide.slide_number; @@ -140,22 +184,30 @@ async function processSlideshow(folderPath) { // For screenshots (no text), do a centered crop let crop; if (isScreenshotSlide) { - // Center crop for screenshots - const topCrop = Math.floor(CROP_AMOUNT / 2); // 285px from top and bottom - crop = { - left: 0, - top: topCrop, - width: INSTAGRAM_WIDTH, - height: INSTAGRAM_HEIGHT - }; - - await sharp(inputFile) - .extract(crop) - .toFile(outputFile); + const image = sharp(inputFile); + const sourceMeta = await image.metadata(); + const sourceHeight = Number(sourceMeta.height) || INSTAGRAM_HEIGHT; + + if (sourceHeight <= INSTAGRAM_HEIGHT) { + crop = await copyImage(inputFile, outputFile); + } else { + const topCrop = Math.floor((sourceHeight - INSTAGRAM_HEIGHT) / 2); + crop = { + left: 0, + top: topCrop, + width: INSTAGRAM_WIDTH, + height: INSTAGRAM_HEIGHT + }; + + await image + .extract(crop) + .toFile(outputFile); + } } else { crop = await cropImage(inputFile, outputFile, textPosition); } + cropBySlide.set(slideNum, crop); console.log(` Slide ${slideNum}: ${textPosition || 'screenshot'} → crop top ${crop.top}px`); } @@ -167,9 +219,10 @@ async function processSlideshow(folderPath) { slides: metadata.slides.map(slide => { const textPosition = slide.text_position || 'top'; const isScreenshot = !slide.text_overlay; - const crop = isScreenshot - ? { top: Math.floor(CROP_AMOUNT / 2) } - : calculateCrop(textPosition); + const crop = cropBySlide.get(slide.slide_number) + || (isScreenshot + ? { top: Math.floor(CROP_AMOUNT / 2) } + : calculateCrop(textPosition)); return { ...slide, diff --git a/code/debug/grok-submit-probe.js b/code/debug/grok-submit-probe.js index 75cd8c5..643a527 100644 --- a/code/debug/grok-submit-probe.js +++ b/code/debug/grok-submit-probe.js @@ -5,11 +5,12 @@ import path from 'path'; import { chromium } from 'playwright'; import { applyCookiesFileToContext, applyStorageStateToContext } from '../shared/grok-browser-session.js'; import { ensureAuthenticatedGrokSession } from '../shared/grok-web-auth.js'; +import { AUTH_DIR, COOKIES_DIR, TEMP_DIR } from '../core/paths.js'; -const OUTPUT_DIR = path.resolve('output/tmp/browser-submit-probe'); -const STATE_PATH = path.resolve('auth/grok-storage-state.json'); -const COOKIES_PATH = path.resolve('cookies/x_cookies.json'); -const USER_DATA_DIR = path.resolve('auth/grok-chrome-profile-web-fallback'); +const OUTPUT_DIR = path.join(TEMP_DIR, 'browser-submit-probe'); +const STATE_PATH = path.join(AUTH_DIR, 'grok-storage-state.json'); +const COOKIES_PATH = path.join(COOKIES_DIR, 'x_cookies.json'); +const USER_DATA_DIR = path.join(AUTH_DIR, 'grok-chrome-profile-web-fallback'); function parseArgs(argv) { const args = { diff --git a/code/posting/auto-post-instagram-ai-video.js b/code/posting/auto-post-instagram-ai-video.js index 8f754b2..ee3061b 100644 --- a/code/posting/auto-post-instagram-ai-video.js +++ b/code/posting/auto-post-instagram-ai-video.js @@ -21,13 +21,12 @@ import { updateScheduledPlatformPost, } from '../shared/scheduled-queue.js'; import { normalizeCaptionForPosting } from '../shared/post-promo.js'; +import { II_ROOT, ROOT_DIR } from '../core/paths.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.join(__dirname, '..', '..'); const PLATFORM = 'instagram'; -// Load environment variables from repo root -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); const POSTS_PER_RUN = 1; @@ -39,17 +38,17 @@ async function postOneVideo(item) { const prepared = prepareScheduledVideoForPlatform(item, PLATFORM); const videoFilePath = prepared.preparedVideoPath; if (!fs.existsSync(videoFilePath)) { - console.error(` File not found: ${path.relative(REPO_ROOT, videoFilePath)}`); + console.error(` File not found: ${path.relative(II_ROOT, videoFilePath)}`); return null; } const fileSizeMB = (fs.statSync(videoFilePath).size / (1024 * 1024)).toFixed(1); - console.log(` File: ${path.relative(REPO_ROOT, videoFilePath)} (${fileSizeMB} MB)`); + console.log(` File: ${path.relative(II_ROOT, videoFilePath)} (${fileSizeMB} MB)`); // Load caption let caption = normalizeCaptionForPosting(`${video.id} #content #video`); if (video.assets.caption_path) { - const captionFilePath = path.join(REPO_ROOT, video.assets.caption_path); + const captionFilePath = path.join(II_ROOT, video.assets.caption_path); if (fs.existsSync(captionFilePath)) { caption = normalizeCaptionForPosting(fs.readFileSync(captionFilePath, 'utf-8')); console.log(` Caption loaded (${caption.length} chars)`); diff --git a/code/posting/auto-post-instagram.js b/code/posting/auto-post-instagram.js index 172b82a..6c781db 100644 --- a/code/posting/auto-post-instagram.js +++ b/code/posting/auto-post-instagram.js @@ -49,6 +49,30 @@ function isValidFolder(folderName) { return hasSlides; } +function hasCompleteInstagramRender(folderPath) { + const instagramFolder = path.join(folderPath, 'instagram'); + if (!fs.existsSync(instagramFolder)) { + return false; + } + + const metadataPath = path.join(folderPath, 'metadata.json'); + if (!fs.existsSync(metadataPath)) { + return false; + } + + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + const expectedSlides = Array.isArray(metadata.slides) ? metadata.slides.length : 0; + if (expectedSlides === 0) { + return false; + } + + const renderedSlides = fs.readdirSync(instagramFolder) + .filter(file => /^slide_\d+\.(jpg|png)$/i.test(file)) + .length; + + return renderedSlides >= expectedSlides; +} + /** * Main auto-post function */ @@ -97,9 +121,7 @@ async function autoPostToInstagram() { const folderPath = selectedFolder.dir; - // Check if already cropped for Instagram - const instagramFolder = path.join(folderPath, 'instagram'); - if (!fs.existsSync(instagramFolder)) { + if (!hasCompleteInstagramRender(folderPath)) { console.log('Cropping for Instagram...'); await processSlideshow(folderPath); console.log(); diff --git a/code/posting/auto-post-tiktok-ai-video.js b/code/posting/auto-post-tiktok-ai-video.js index ef6e348..2f433ab 100644 --- a/code/posting/auto-post-tiktok-ai-video.js +++ b/code/posting/auto-post-tiktok-ai-video.js @@ -21,12 +21,12 @@ import { updateScheduledPlatformPost, } from '../shared/scheduled-queue.js'; import { normalizeCaptionForPosting } from '../shared/post-promo.js'; +import { II_ROOT, ROOT_DIR } from '../core/paths.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.join(__dirname, '..', '..'); const PLATFORM = 'tiktok'; -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); const POSTS_PER_RUN = 1; @@ -38,17 +38,17 @@ async function postOneVideo(item) { const prepared = prepareScheduledVideoForPlatform(item, PLATFORM); const videoFilePath = prepared.preparedVideoPath; if (!fs.existsSync(videoFilePath)) { - console.error(` File not found: ${path.relative(REPO_ROOT, videoFilePath)}`); + console.error(` File not found: ${path.relative(II_ROOT, videoFilePath)}`); return null; } const fileSizeMB = (fs.statSync(videoFilePath).size / (1024 * 1024)).toFixed(1); - console.log(` File: ${path.relative(REPO_ROOT, videoFilePath)} (${fileSizeMB} MB)`); + console.log(` File: ${path.relative(II_ROOT, videoFilePath)} (${fileSizeMB} MB)`); // Load caption let caption = normalizeCaptionForPosting(`${video.id} #content #video`); if (video.assets.caption_path) { - const captionFilePath = path.join(REPO_ROOT, video.assets.caption_path); + const captionFilePath = path.join(II_ROOT, video.assets.caption_path); if (fs.existsSync(captionFilePath)) { caption = normalizeCaptionForPosting(fs.readFileSync(captionFilePath, 'utf-8')); console.log(` Caption loaded (${caption.length} chars)`); diff --git a/code/posting/auto-post-x-ai-video.js b/code/posting/auto-post-x-ai-video.js index ce7d010..8f808da 100644 --- a/code/posting/auto-post-x-ai-video.js +++ b/code/posting/auto-post-x-ai-video.js @@ -30,10 +30,10 @@ import { extractXaiChatUsageMetadata, recordApiSpend, } from '../shared/api-spend-tracker.js'; +import { II_ROOT, ROOT_DIR } from '../core/paths.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.join(__dirname, '..', '..'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); const PLATFORM = 'x'; const CHAT_MODEL = 'grok-4-1-fast-non-reasoning'; @@ -48,7 +48,7 @@ async function generateTweetCaption(video) { // Load caption file for context let captionContext = ''; if (video.assets.caption_path) { - const captionFilePath = path.join(REPO_ROOT, video.assets.caption_path); + const captionFilePath = path.join(II_ROOT, video.assets.caption_path); if (fs.existsSync(captionFilePath)) { captionContext = fs.readFileSync(captionFilePath, 'utf-8').trim(); } @@ -160,7 +160,7 @@ async function main() { const prepared = prepareScheduledVideoForPlatform(item, PLATFORM); const videoFilePath = prepared.preparedVideoPath; if (!fs.existsSync(videoFilePath)) { - console.error(`File not found: ${path.relative(REPO_ROOT, videoFilePath)}`); + console.error(`File not found: ${path.relative(II_ROOT, videoFilePath)}`); process.exit(1); } diff --git a/code/posting/instagram-auth.js b/code/posting/instagram-auth.js index dc0e5a1..2c01a5e 100644 --- a/code/posting/instagram-auth.js +++ b/code/posting/instagram-auth.js @@ -4,13 +4,13 @@ import readline from 'readline'; import { chromium } from 'playwright'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; +import { COOKIES_DIR, ROOT_DIR } from '../core/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.join(__dirname, '..', '..'); -const COOKIE_FILE = path.join(REPO_ROOT, 'cookies', 'instagram_cookies.json'); +const COOKIE_FILE = path.join(COOKIES_DIR, 'instagram_cookies.json'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); function chromiumLaunchOptions(headless) { const executablePath = fs.existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined; diff --git a/code/posting/instagram-browser-post.js b/code/posting/instagram-browser-post.js index 9db4c94..04301b4 100644 --- a/code/posting/instagram-browser-post.js +++ b/code/posting/instagram-browser-post.js @@ -9,13 +9,13 @@ import { ensureInstagramLoggedIn, saveCookies, } from './instagram-auth.js'; +import { COOKIES_DIR, II_ROOT, ROOT_DIR } from '../core/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.join(__dirname, '..', '..'); -const COOKIE_FILE = path.join(REPO_ROOT, 'cookies', 'instagram_cookies.json'); +const COOKIE_FILE = path.join(COOKIES_DIR, 'instagram_cookies.json'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); function chromiumLaunchOptions(headless) { const executablePath = fs.existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined; @@ -122,11 +122,11 @@ async function extractPermalink(page, username, caption) { async function saveDebugScreenshot(page, label) { try { - const debugDir = path.join(REPO_ROOT, 'output', 'debug'); + const debugDir = path.join(II_ROOT, 'debug'); fs.mkdirSync(debugDir, { recursive: true }); const filename = `instagram-${label}-${Date.now()}.png`; await page.screenshot({ path: path.join(debugDir, filename), fullPage: false }); - console.log(` [debug] Screenshot saved: output/debug/${filename}`); + console.log(` [debug] Screenshot saved: ${path.join(debugDir, filename)}`); return filename; } catch { return null; diff --git a/code/posting/post-to-instagram.js b/code/posting/post-to-instagram.js index 74615cf..ca7fc3e 100644 --- a/code/posting/post-to-instagram.js +++ b/code/posting/post-to-instagram.js @@ -11,13 +11,12 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; -import { SCHEDULED_CAROUSELS_DIR } from '../core/paths.js'; +import { II_ROOT, ROOT_DIR, SCHEDULED_CAROUSELS_DIR } from '../core/paths.js'; import { resolveScheduledItem, updateScheduledPlatformPost } from '../shared/scheduled-queue.js'; import { postToInstagramViaBrowser } from './instagram-browser-post.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.join(__dirname, '..', '..'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); function resolveFolderPath(folderPath) { if (path.isAbsolute(folderPath)) return folderPath; @@ -118,7 +117,7 @@ async function postToInstagram(folderPath) { updateScheduledPlatformPost('carousel', resolvedFolder, 'instagram', { post_id: result.postId, permalink: result.postUrl, - source_file: path.relative(REPO_ROOT, resolvedFolder), + source_file: path.relative(II_ROOT, resolvedFolder), }); return result; diff --git a/code/posting/post-to-x.js b/code/posting/post-to-x.js index 89baeec..fc62939 100644 --- a/code/posting/post-to-x.js +++ b/code/posting/post-to-x.js @@ -12,7 +12,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; import { postToXViaBrowser } from './x-browser-post.js'; -import { SCHEDULED_CAROUSELS_DIR } from '../core/paths.js'; +import { II_ROOT, ROOT_DIR, SCHEDULED_CAROUSELS_DIR } from '../core/paths.js'; import { resolveScheduledItem, updateScheduledPlatformPost } from '../shared/scheduled-queue.js'; import { assertSpendWithinLimit, @@ -23,8 +23,7 @@ import { } from '../shared/api-spend-tracker.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.join(__dirname, '..', '..'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); // --- HASHTAG POOLS (rotate to avoid repetition penalty) --- const BROAD_HASHTAGS = ['#creators', '#marketing', '#content', '#video', '#automation', '#socialmedia', '#creative']; @@ -155,9 +154,14 @@ Return ONLY the tweet text with line breaks. Nothing else.`; * 3. Skip low-value CTA slides when possible * Prefer instagram/ cropped (4:5) when available, fall back to original 9:16 */ +function folderHasSlideImages(folderPath) { + return fs.existsSync(folderPath) + && fs.readdirSync(folderPath).some(f => /^slide_\d+\.(jpg|png)$/i.test(f)); +} + function selectImages(folderPath) { const instagramFolder = path.join(folderPath, 'instagram'); - const useInstagram = fs.existsSync(instagramFolder); + const useInstagram = folderHasSlideImages(instagramFolder); const imageFolder = useInstagram ? instagramFolder : folderPath; // Load metadata for slide info @@ -285,7 +289,7 @@ async function postToX(folderPath) { updateScheduledPlatformPost('carousel', folderPath, 'x', { post_id: tweetId, permalink: tweetUrl, - source_file: path.relative(REPO_ROOT, folderPath), + source_file: path.relative(II_ROOT, folderPath), }); return { tweetId, tweetUrl }; @@ -328,3 +332,4 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { } export { postToX }; +export { selectImages }; diff --git a/code/posting/tiktok-browser-post.js b/code/posting/tiktok-browser-post.js index cf46a60..370a02e 100644 --- a/code/posting/tiktok-browser-post.js +++ b/code/posting/tiktok-browser-post.js @@ -21,13 +21,13 @@ import path from 'path'; import { chromium } from 'playwright'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; +import { COOKIES_DIR, II_ROOT, ROOT_DIR } from '../core/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.join(__dirname, '..', '..'); -const COOKIE_FILE = path.join(REPO_ROOT, 'cookies', 'tiktok_cookies.json'); +const COOKIE_FILE = path.join(COOKIES_DIR, 'tiktok_cookies.json'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); let resolvedUsername = (process.env.TIKTOK_ACCOUNT_NAME || '').replace(/^@/, ''); const CREATOR_UPLOAD_URL = 'https://www.tiktok.com/creator#/upload?scene=creator_center'; @@ -100,11 +100,11 @@ async function saveCookies(context) { async function saveDebugScreenshot(page, label) { try { - const debugDir = path.join(REPO_ROOT, 'output', 'debug'); + const debugDir = path.join(II_ROOT, 'debug'); fs.mkdirSync(debugDir, { recursive: true }); const filename = `tiktok-${label}-${Date.now()}.png`; await page.screenshot({ path: path.join(debugDir, filename), fullPage: false }); - console.log(` [debug] Screenshot saved: output/debug/${filename}`); + console.log(` [debug] Screenshot saved: ${path.join(debugDir, filename)}`); return filename; } catch { return null; diff --git a/code/posting/x-browser-post.js b/code/posting/x-browser-post.js index f04e33e..b2f1649 100644 --- a/code/posting/x-browser-post.js +++ b/code/posting/x-browser-post.js @@ -3,13 +3,13 @@ import path from 'path'; import { chromium } from 'playwright'; import { fileURLToPath } from 'url'; import dotenv from 'dotenv'; +import { COOKIES_DIR, II_ROOT, ROOT_DIR } from '../core/paths.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const REPO_ROOT = path.join(__dirname, '..', '..'); -const COOKIE_FILE = path.join(REPO_ROOT, 'cookies', 'x_cookies.json'); +const COOKIE_FILE = path.join(COOKIES_DIR, 'x_cookies.json'); -dotenv.config({ path: path.join(REPO_ROOT, '.env') }); +dotenv.config({ path: path.join(ROOT_DIR, '.env') }); function chromiumLaunchOptions(headless) { const executablePath = fs.existsSync('/usr/bin/chromium') ? '/usr/bin/chromium' : undefined; @@ -23,11 +23,11 @@ function chromiumLaunchOptions(headless) { async function saveDebugScreenshot(page, label) { try { - const debugDir = path.join(REPO_ROOT, 'output', 'debug'); + const debugDir = path.join(II_ROOT, 'debug'); fs.mkdirSync(debugDir, { recursive: true }); const filename = `x-${label}-${Date.now()}.png`; await page.screenshot({ path: path.join(debugDir, filename), fullPage: false }); - console.log(` [debug] Screenshot saved: output/debug/${filename}`); + console.log(` [debug] Screenshot saved: ${path.join(debugDir, filename)}`); return filename; } catch { return null; @@ -102,6 +102,13 @@ function captionFingerprint(text, maxLength = 60) { .slice(0, maxLength); } +function hasTransientComposerError(bodyText) { + const normalized = String(bodyText || '').toLowerCase(); + return normalized.includes('something went wrong') + || normalized.includes('let’s give it another shot') + || normalized.includes("let's give it another shot"); +} + async function extractUsername(page) { const href = await page.evaluate(() => { const profileLink = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); @@ -144,7 +151,11 @@ async function attachMedia(page, mediaPaths) { const input = page.locator('input[type="file"]').first(); await input.waitFor({ timeout: 30000 }); await input.setInputFiles(mediaPaths); - await page.waitForTimeout(4000); + await page.waitForFunction((expectedCount) => { + const previews = document.querySelectorAll('img[src*="media"], video, [data-testid="attachments"] img'); + return previews.length >= expectedCount; + }, mediaPaths.length, { timeout: 60000 }).catch(() => {}); + await waitForComposerToSettle(page); } async function waitForSubmitEnabled(page, timeoutMs = 180000) { @@ -159,56 +170,82 @@ async function waitForSubmitEnabled(page, timeoutMs = 180000) { }, { timeout: timeoutMs }); } +async function waitForComposerToSettle(page, timeoutMs = 120000) { + await page.waitForFunction(() => { + const dialog = document.querySelector('[aria-label="Drafts"]')?.closest('[role="dialog"]'); + const root = dialog || document.body; + const bodyText = String(root?.innerText || '').toLowerCase(); + const hasBusyUi = Boolean(root?.querySelector('[role="progressbar"], [aria-busy="true"]')); + const hasUploadText = /uploading|processing|finalizing/i.test(bodyText); + return !hasBusyUi && !hasUploadText; + }, { timeout: timeoutMs }).catch(() => {}); + + await page.waitForTimeout(3000); +} + async function findPostedStatusUrl(page, username, expectedText) { const expected = captionFingerprint(expectedText, 40); if (!username) { return ''; } - await page.goto(`https://x.com/${username}`, { - waitUntil: 'domcontentloaded', - timeout: 60000, - }); - await page.waitForTimeout(5000); - - return page.evaluate((needle) => { - const articles = Array.from(document.querySelectorAll('article')); - for (const article of articles) { - const text = String(article.innerText || '').toLowerCase(); - if (!text.includes(needle)) { - continue; - } - const link = article.querySelector('a[href*="/status/"]'); - if (link) { - return link.href || link.getAttribute('href') || ''; + const lookupPage = await page.context().newPage(); + + try { + await lookupPage.goto(`https://x.com/${username}`, { + waitUntil: 'domcontentloaded', + timeout: 60000, + }); + await lookupPage.waitForTimeout(5000); + + return await lookupPage.evaluate((needle) => { + const articles = Array.from(document.querySelectorAll('article')); + for (const article of articles) { + const text = String(article.innerText || '').toLowerCase(); + if (!text.includes(needle)) { + continue; + } + const link = article.querySelector('a[href*="/status/"]'); + if (link) { + return link.href || link.getAttribute('href') || ''; + } } - } - return ''; - }, expected).then(normalizeXUrl).catch(() => ''); + return ''; + }, expected).then(normalizeXUrl).catch(() => ''); + } finally { + await lookupPage.close().catch(() => {}); + } } async function submitPost(page, expectedText, username) { - const submitButton = page.locator('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]').first(); let lastError = ''; for (let attempt = 1; attempt <= 3; attempt += 1) { + const submitButton = page.locator('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]').first(); + await submitButton.waitFor({ timeout: 30000 }); await submitButton.click(); console.log(` [debug] Submit attempt ${attempt}`); await page.waitForTimeout(10000); await saveDebugScreenshot(page, `05-after-submit-${attempt}`); + const currentUrl = page.url(); + if (/\/status\//.test(currentUrl)) { + return normalizeXUrl(currentUrl); + } + const body = await page.locator('body').innerText().catch(() => ''); - if (/something went wrong/i.test(body)) { + if (hasTransientComposerError(body)) { lastError = 'X composer returned a transient submit error'; console.log(` [debug] Submit error after attempt ${attempt}`); + await page.waitForTimeout(12000); + const delayedProfileStatusUrl = await findPostedStatusUrl(page, username, expectedText); + if (delayedProfileStatusUrl) { + return delayedProfileStatusUrl; + } + await waitForComposerToSettle(page, 30000); continue; } - const currentUrl = page.url(); - if (/\/status\//.test(currentUrl)) { - return normalizeXUrl(currentUrl); - } - const profileStatusUrl = await findPostedStatusUrl(page, username, expectedText); if (profileStatusUrl) { return profileStatusUrl; @@ -270,3 +307,6 @@ export async function postToXViaBrowser({ text, mediaPaths = [], headless = true await browser.close(); } } + +export { hasTransientComposerError }; +export { findPostedStatusUrl }; diff --git a/code/posting/x_browser_post.py b/code/posting/x_browser_post.py index b695d99..300e937 100644 --- a/code/posting/x_browser_post.py +++ b/code/posting/x_browser_post.py @@ -26,8 +26,8 @@ from zendriver.core.config import Config as ZDConfig -REPO_ROOT = Path(__file__).resolve().parent.parent.parent -COOKIE_FILE = REPO_ROOT / "cookies" / "x_cookies.json" +II_ROOT = Path(os.environ.get("II_ROOT", str(Path.home() / "ii" / "content-engine"))) +COOKIE_FILE = II_ROOT / "cookies" / "x_cookies.json" CHROME_PATH_MACOS = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" CHROMIUM_PATH_LINUX = "/usr/bin/chromium" @@ -70,13 +70,13 @@ async def sleep_brief(seconds=1.5): async def save_debug_screenshot(page, label): try: - debug_dir = REPO_ROOT / "output" / "debug" + debug_dir = II_ROOT / "debug" debug_dir.mkdir(parents=True, exist_ok=True) import time filename = f"x-{label}-{int(time.time())}.png" filepath = debug_dir / filename await page.save_screenshot(filepath, format="png") - print(f" [debug] Screenshot saved: output/debug/{filename}", file=sys.stderr) + print(f" [debug] Screenshot saved: {filepath}", file=sys.stderr) return str(filepath) except Exception as e: print(f" [debug] Screenshot failed: {e}", file=sys.stderr) diff --git a/code/shared/generate-image.js b/code/shared/generate-image.js index 1dbf186..617f978 100644 --- a/code/shared/generate-image.js +++ b/code/shared/generate-image.js @@ -7,7 +7,7 @@ import path from 'path'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; import sharp from 'sharp'; -import { AUTH_DIR, ROOT_DIR, TEMP_DIR } from '../core/paths.js'; +import { AUTH_DIR, COOKIES_DIR, TEMP_DIR } from '../core/paths.js'; import { assertSpendWithinLimit, estimateXaiImageCost, @@ -22,7 +22,7 @@ const RATE_LIMIT_WAIT = 5000; // 5 seconds between requests const DEFAULT_GROK_STORAGE_PATH = path.join(AUTH_DIR, 'grok-storage-state.json'); const DEFAULT_GROK_COOKIES_PATH = path.join(AUTH_DIR, 'grok-session-cookies.json'); const DEFAULT_GROK_USER_DATA_DIR = path.join(AUTH_DIR, 'grok-chrome-profile-web-fallback'); -const DEFAULT_X_COOKIES_PATH = path.join(ROOT_DIR, 'cookies', 'x_cookies.json'); +const DEFAULT_X_COOKIES_PATH = path.join(COOKIES_DIR, 'x_cookies.json'); const DEFAULT_BROWSER_IMAGE_DOWNLOAD_DIR = path.join(TEMP_DIR, 'grok-images'); export const IMAGE_MODELS = { diff --git a/code/shared/scheduled-queue.js b/code/shared/scheduled-queue.js index f9581f7..def101a 100644 --- a/code/shared/scheduled-queue.js +++ b/code/shared/scheduled-queue.js @@ -3,6 +3,7 @@ import path from 'path'; import { execFileSync } from 'child_process'; import { CAROUSELS_DIR, + II_ROOT, POSTED_VIDEOS_DIR, ROOT_DIR, SCHEDULED_CAROUSELS_DIR, @@ -15,9 +16,12 @@ import { } from './post-promo.js'; const SCHEDULE_FILE = 'schedule.json'; -const DEFAULT_POST_OUTRO_PATH = '/Users/admin/Documents/plug.mov'; +const DEFAULT_POST_OUTRO_PATH = process.env.CONTENT_ENGINE_POST_OUTRO_PATH || 'assets/plug.mov'; const DEFAULT_RETRY_DELAY_MS = 15 * 60 * 1000; const MAX_RETRY_DELAY_MS = 6 * 60 * 60 * 1000; +const LOCK_DIR_SUFFIX = '.lock'; +const LOCK_RETRY_MS = 50; +const LOCK_STALE_MS = 5 * 60 * 1000; function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); @@ -36,11 +40,25 @@ function safeSlug(value, fallback) { } function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + let lastError = null; + + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + lastError = error; + sleepSync(LOCK_RETRY_MS * (attempt + 1)); + } + } + + throw new Error(`Invalid JSON in ${filePath}: ${lastError?.message || 'unknown parse error'}`); } function writeJson(filePath, data) { - fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + ensureDir(path.dirname(filePath)); + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); + fs.renameSync(tempPath, filePath); } function isoNow() { @@ -55,6 +73,48 @@ function rewriteRelativePathPrefix(value, fromPrefix, toPrefix) { return `${toPrefix}${normalized.slice(fromPrefix.length)}`; } +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function withFileLock(filePath, callback) { + const lockDir = `${filePath}${LOCK_DIR_SUFFIX}`; + const startedAt = Date.now(); + + while (true) { + try { + fs.mkdirSync(lockDir); + break; + } catch (error) { + if (error?.code !== 'EEXIST') { + throw error; + } + + try { + const stat = fs.statSync(lockDir); + if ((Date.now() - stat.mtimeMs) > LOCK_STALE_MS) { + fs.rmSync(lockDir, { recursive: true, force: true }); + continue; + } + } catch { + continue; + } + + if ((Date.now() - startedAt) > LOCK_STALE_MS) { + throw new Error(`Timed out waiting for scheduled queue lock: ${filePath}`); + } + + sleepSync(LOCK_RETRY_MS); + } + } + + try { + return callback(); + } finally { + fs.rmSync(lockDir, { recursive: true, force: true }); + } +} + function manifestPlatformsForType(type, manifest) { const fallback = defaultPlatforms(type); const posts = manifest?.posts || fallback; @@ -188,7 +248,7 @@ function isManifestFullyPosted(type, manifest) { } function relativeToRepo(filePath) { - return path.relative(ROOT_DIR, filePath); + return path.relative(II_ROOT, filePath); } function scheduleRoot(type) { @@ -201,22 +261,30 @@ function defaultPlatforms(type) { : { instagram: null, x: null }; } -function loadManifestFromDir(itemDir) { +function loadManifestFromDir(itemDir, { swallowErrors = false } = {}) { const manifestPath = path.join(itemDir, SCHEDULE_FILE); if (!fs.existsSync(manifestPath)) { return null; } - const manifest = readJson(manifestPath); - const changed = ensureScheduledManifestDefaults(manifest.type, manifest); - if (changed) { - writeJson(manifestPath, manifest); + try { + const manifest = readJson(manifestPath); + const changed = ensureScheduledManifestDefaults(manifest.type, manifest); + if (changed) { + writeJson(manifestPath, manifest); + } + return { + dir: itemDir, + manifestPath, + manifest, + }; + } catch (error) { + if (swallowErrors) { + console.error(`Skipping invalid scheduled manifest in ${itemDir}: ${error.message}`); + return null; + } + throw error; } - return { - dir: itemDir, - manifestPath, - manifest, - }; } export function listScheduledItems(type) { @@ -225,7 +293,7 @@ export function listScheduledItems(type) { return fs.readdirSync(rootDir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) - .map(entry => loadManifestFromDir(path.join(rootDir, entry.name))) + .map(entry => loadManifestFromDir(path.join(rootDir, entry.name), { swallowErrors: true })) .filter(Boolean) .sort((a, b) => { const aTime = a.manifest.scheduled_at || ''; @@ -254,103 +322,134 @@ export function listScheduledPlatformItems(type, platform) { export function resolveScheduledItem(type, idOrPath) { if (path.isAbsolute(idOrPath)) { - return loadManifestFromDir(idOrPath); + return loadManifestFromDir(idOrPath, { swallowErrors: true }); } const direct = path.join(scheduleRoot(type), idOrPath); if (fs.existsSync(direct)) { - return loadManifestFromDir(direct); + return loadManifestFromDir(direct, { swallowErrors: true }); } return null; } -export function updateScheduledPlatformPost(type, itemDir, platform, details) { +function lockPathForItem(itemDir) { + return path.join(itemDir, SCHEDULE_FILE); +} + +function loadManifestForMutation(type, itemDir) { const item = loadManifestFromDir(itemDir); if (!item) { throw new Error(`Missing scheduled manifest in ${itemDir}`); } - item.manifest.posts = item.manifest.posts || defaultPlatforms(type); ensureQueueState(type, item.manifest); - item.manifest.posts[platform] = { - ...details, - platform, - posted_at: details.posted_at || isoNow(), - }; - item.manifest.queue.platforms[platform] = { - ...item.manifest.queue.platforms[platform], - status: 'posted', - posted_at: item.manifest.posts[platform].posted_at, - last_attempt_at: item.manifest.posts[platform].posted_at, - next_attempt_at: null, - last_error: null, - }; - item.manifest.queue.overall.status = isManifestFullyPosted(type, item.manifest) ? 'posted' : 'queued'; + return item; +} - writeJson(item.manifestPath, item.manifest); +export function resolvePostOutroPath(manifest = {}) { + const configured = String( + manifest?.post_defaults?.outro_path + || process.env.CONTENT_ENGINE_POST_OUTRO_PATH + || DEFAULT_POST_OUTRO_PATH, + ).trim(); - if (type === 'video' && isManifestFullyPosted(type, item.manifest)) { - return archiveScheduledVideoItem(item.dir); + if (!configured) { + return ''; } - return item.manifest; + return path.isAbsolute(configured) + ? configured + : path.join(II_ROOT, configured); } -export function archiveScheduledVideoItem(itemDir) { - const item = loadManifestFromDir(itemDir); - if (!item) { - throw new Error(`Missing scheduled manifest in ${itemDir}`); +export function updateScheduledPlatformPost(type, itemDir, platform, details) { + let shouldArchive = false; + const manifest = withFileLock(lockPathForItem(itemDir), () => { + const item = loadManifestForMutation(type, itemDir); + item.manifest.posts = item.manifest.posts || defaultPlatforms(type); + item.manifest.posts[platform] = { + ...details, + platform, + posted_at: details.posted_at || isoNow(), + }; + item.manifest.queue.platforms[platform] = { + ...item.manifest.queue.platforms[platform], + status: 'posted', + posted_at: item.manifest.posts[platform].posted_at, + last_attempt_at: item.manifest.posts[platform].posted_at, + next_attempt_at: null, + last_error: null, + }; + item.manifest.queue.overall.status = isManifestFullyPosted(type, item.manifest) ? 'posted' : 'queued'; + writeJson(item.manifestPath, item.manifest); + shouldArchive = type === 'video' && isManifestFullyPosted(type, item.manifest); + return item.manifest; + }); + + if (shouldArchive) { + return archiveScheduledVideoItem(itemDir); } - ensureDir(POSTED_VIDEOS_DIR); + return manifest; +} + +export function archiveScheduledVideoItem(itemDir) { + return withFileLock(lockPathForItem(itemDir), () => { + const item = loadManifestFromDir(itemDir); + if (!item) { + throw new Error(`Missing scheduled manifest in ${itemDir}`); + } - const slug = path.basename(item.dir); - const targetDir = path.join(POSTED_VIDEOS_DIR, slug); - const oldRelativeDir = relativeToRepo(item.dir); - const newRelativeDir = relativeToRepo(targetDir); + ensureDir(POSTED_VIDEOS_DIR); - fs.rmSync(targetDir, { recursive: true, force: true }); - fs.renameSync(item.dir, targetDir); + const slug = path.basename(item.dir); + const targetDir = path.join(POSTED_VIDEOS_DIR, slug); + const oldRelativeDir = relativeToRepo(item.dir); + const newRelativeDir = relativeToRepo(targetDir); - const movedItem = loadManifestFromDir(targetDir); - if (!movedItem) { - throw new Error(`Archived scheduled video is missing its manifest: ${targetDir}`); - } + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.renameSync(item.dir, targetDir); - const manifest = movedItem.manifest; - manifest.archived_from_queue_at = isoNow(); - manifest.queue_status = 'posted'; - manifest.archived_from = oldRelativeDir; - ensureQueueState('video', manifest); - manifest.queue.overall.status = 'posted'; - - if (manifest.assets) { - for (const [key, value] of Object.entries(manifest.assets)) { - if (typeof value === 'string') { - manifest.assets[key] = rewriteRelativePathPrefix(value, oldRelativeDir, newRelativeDir); + const movedItem = loadManifestFromDir(targetDir); + if (!movedItem) { + throw new Error(`Archived scheduled video is missing its manifest: ${targetDir}`); + } + + const manifest = movedItem.manifest; + manifest.archived_from_queue_at = isoNow(); + manifest.queue_status = 'posted'; + manifest.archived_from = oldRelativeDir; + ensureQueueState('video', manifest); + manifest.queue.overall.status = 'posted'; + + if (manifest.assets) { + for (const [key, value] of Object.entries(manifest.assets)) { + if (typeof value === 'string') { + manifest.assets[key] = rewriteRelativePathPrefix(value, oldRelativeDir, newRelativeDir); + } } } - } - if (manifest.posts && typeof manifest.posts === 'object') { - for (const post of Object.values(manifest.posts)) { - if (post && typeof post === 'object' && typeof post.source_file === 'string') { - post.source_file = rewriteRelativePathPrefix(post.source_file, oldRelativeDir, newRelativeDir); + if (manifest.posts && typeof manifest.posts === 'object') { + for (const post of Object.values(manifest.posts)) { + if (post && typeof post === 'object' && typeof post.source_file === 'string') { + post.source_file = rewriteRelativePathPrefix(post.source_file, oldRelativeDir, newRelativeDir); + } } } - } - if (manifest.queue?.platforms && typeof manifest.queue.platforms === 'object') { - for (const state of Object.values(manifest.queue.platforms)) { - if (state && typeof state === 'object' && typeof state.prepared_video_path === 'string') { - state.prepared_video_path = rewriteRelativePathPrefix(state.prepared_video_path, oldRelativeDir, newRelativeDir); + if (manifest.queue?.platforms && typeof manifest.queue.platforms === 'object') { + for (const state of Object.values(manifest.queue.platforms)) { + if (state && typeof state === 'object' && typeof state.prepared_video_path === 'string') { + state.prepared_video_path = rewriteRelativePathPrefix(state.prepared_video_path, oldRelativeDir, newRelativeDir); + } } } - } - writeJson(movedItem.manifestPath, manifest); - return manifest; + writeJson(movedItem.manifestPath, manifest); + return manifest; + }); } function detectVideoAssets(sourceDir) { @@ -377,15 +476,15 @@ function copyFileIfPresent(fromPath, toPath) { } } -function appendPostOutro(sourceVideoPath, targetVideoPath) { - if (!fs.existsSync(DEFAULT_POST_OUTRO_PATH)) { - throw new Error(`Required post outro clip not found: ${DEFAULT_POST_OUTRO_PATH}`); +function appendPostOutro(sourceVideoPath, targetVideoPath, outroPath) { + if (!fs.existsSync(outroPath)) { + throw new Error(`Required post outro clip not found: ${outroPath}`); } execFileSync('ffmpeg', [ '-y', '-i', sourceVideoPath, - '-i', DEFAULT_POST_OUTRO_PATH, + '-i', outroPath, '-filter_complex', '[0:v:0][0:a:0][1:v:0][1:a:0]concat=n=2:v=1:a=1[v][a]', '-map', '[v]', '-map', '[a]', @@ -404,19 +503,19 @@ function resolveQueuedVideoSource(item) { const candidates = []; const sourceFolder = item?.manifest?.source?.folder; if (sourceFolder) { - const sourceDir = path.join(ROOT_DIR, sourceFolder); + const sourceDir = path.join(II_ROOT, sourceFolder); const baseName = path.basename(sourceDir); candidates.push(path.join(sourceDir, `${baseName}.mp4`)); } const rawVideoPath = item?.manifest?.assets?.raw_video_path; if (rawVideoPath) { - candidates.push(path.join(ROOT_DIR, rawVideoPath)); + candidates.push(path.join(II_ROOT, rawVideoPath)); } const queuedVideoPath = item?.manifest?.assets?.video_path; if (queuedVideoPath) { - candidates.push(path.join(ROOT_DIR, queuedVideoPath)); + candidates.push(path.join(II_ROOT, queuedVideoPath)); } return candidates.find((candidate) => candidate && fs.existsSync(candidate)) || null; @@ -437,19 +536,31 @@ export function prepareScheduledVideoForPlatform(item, platform) { const preparedDir = path.join(item.dir, 'platform-renders', platform); ensureDir(preparedDir); const preparedPath = path.join(preparedDir, `${item.manifest.id}-${platform}.mp4`); - appendPostOutro(sourceVideoPath, preparedPath); + const shouldAppendOutro = item.manifest?.post_defaults?.append_outro_before_post !== false; + const outroPath = resolvePostOutroPath(item.manifest); + + if (shouldAppendOutro) { + appendPostOutro(sourceVideoPath, preparedPath, outroPath); + } else { + fs.copyFileSync(sourceVideoPath, preparedPath); + } const preparedAt = isoNow(); - item.manifest.queue.platforms[platform] = { - ...item.manifest.queue.platforms[platform], - status: 'prepared', - prepared_video_path: relativeToRepo(preparedPath), - last_attempt_at: preparedAt, - next_attempt_at: preparedAt, - last_error: null, - }; - item.manifest.queue.overall.last_prepared_at = preparedAt; - writeJson(item.manifestPath, item.manifest); + withFileLock(lockPathForItem(item.dir), () => { + const freshItem = loadManifestForMutation('video', item.dir); + freshItem.manifest.queue.platforms[platform] = { + ...freshItem.manifest.queue.platforms[platform], + status: 'prepared', + prepared_video_path: relativeToRepo(preparedPath), + last_attempt_at: preparedAt, + next_attempt_at: preparedAt, + last_error: null, + }; + freshItem.manifest.queue.overall.last_prepared_at = preparedAt; + writeJson(freshItem.manifestPath, freshItem.manifest); + item.manifest = freshItem.manifest; + item.manifestPath = freshItem.manifestPath; + }); return { ...item, @@ -459,28 +570,25 @@ export function prepareScheduledVideoForPlatform(item, platform) { } export function recordScheduledPlatformFailure(type, itemDir, platform, error) { - const item = loadManifestFromDir(itemDir); - if (!item) { - throw new Error(`Missing scheduled manifest in ${itemDir}`); - } - - ensureQueueState(type, item.manifest); - const now = Date.now(); - const state = item.manifest.queue.platforms[platform] || defaultQueueState(type, item.manifest).platforms[platform]; - const retryCount = (Number(state.retry_count) || 0) + 1; - const nextAttemptAt = new Date(now + retryDelayMs(retryCount)).toISOString(); - - item.manifest.queue.platforms[platform] = { - ...state, - status: 'failed', - retry_count: retryCount, - last_attempt_at: new Date(now).toISOString(), - next_attempt_at: nextAttemptAt, - last_error: String(error?.message || error || 'Unknown posting error'), - }; + return withFileLock(lockPathForItem(itemDir), () => { + const item = loadManifestForMutation(type, itemDir); + const now = Date.now(); + const state = item.manifest.queue.platforms[platform] || defaultQueueState(type, item.manifest).platforms[platform]; + const retryCount = (Number(state.retry_count) || 0) + 1; + const nextAttemptAt = new Date(now + retryDelayMs(retryCount)).toISOString(); + + item.manifest.queue.platforms[platform] = { + ...state, + status: 'failed', + retry_count: retryCount, + last_attempt_at: new Date(now).toISOString(), + next_attempt_at: nextAttemptAt, + last_error: String(error?.message || error || 'Unknown posting error'), + }; - writeJson(item.manifestPath, item.manifest); - return item.manifest; + writeJson(item.manifestPath, item.manifest); + return item.manifest; + }); } export function scheduleVideo(sourceDir, options = {}) { diff --git a/code/video/generate-video-compilation.js b/code/video/generate-video-compilation.js index 9a3287f..3c78b0b 100644 --- a/code/video/generate-video-compilation.js +++ b/code/video/generate-video-compilation.js @@ -4,7 +4,7 @@ import path from 'path'; import axios from 'axios'; import { execFileSync } from 'child_process'; import { fileURLToPath } from 'url'; -import { AUTH_DIR, ROOT_DIR, isMainModule } from '../core/paths.js'; +import { AUTH_DIR, COOKIES_DIR, ROOT_DIR, isMainModule } from '../core/paths.js'; import { buildVideoExecutionPlan, loadVideoExecutionPlan, @@ -31,7 +31,7 @@ const BASE_URL = 'https://api.x.ai/v1'; const DEFAULT_GROK_STORAGE_PATH = path.join(AUTH_DIR, 'grok-storage-state.json'); const DEFAULT_GROK_COOKIES_PATH = path.join(AUTH_DIR, 'grok-session-cookies.json'); const DEFAULT_GROK_USER_DATA_DIR = path.join(AUTH_DIR, 'grok-chrome-profile-web-fallback'); -const DEFAULT_X_COOKIES_PATH = path.join(ROOT_DIR, 'cookies', 'x_cookies.json'); +const DEFAULT_X_COOKIES_PATH = path.join(COOKIES_DIR, 'x_cookies.json'); function effectiveXaiApiKey() { return isBrowserOverrideEnabled() ? null : XAI_API_KEY; diff --git a/docker-compose.yml b/docker-compose.yml index d6ed870..9748fab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,14 +13,11 @@ services: PLAYWRIGHT_BROWSERS_PATH: /ms-playwright CONTENT_GEN_PYTHON: /usr/bin/python3 CONTENT_GEN_DISABLE_BROWSER_SANDBOX: "1" + II_ROOT: /app/output env_file: - .env volumes: - - ./output:/app/output - - ./downloads:/app/downloads - - ./cookies:/app/cookies - - ./auth:/app/auth - - ./research:/app/research + - ${II_ROOT:-~/ii/content-engine}:/app/output - ./.env:/app/.env:ro generate: @@ -37,12 +34,9 @@ services: PLAYWRIGHT_BROWSERS_PATH: /ms-playwright CONTENT_GEN_PYTHON: /usr/bin/python3 CONTENT_GEN_DISABLE_BROWSER_SANDBOX: "1" + II_ROOT: /app/output env_file: - .env volumes: - - ./output:/app/output - - ./downloads:/app/downloads - - ./cookies:/app/cookies - - ./auth:/app/auth - - ./research:/app/research + - ${II_ROOT:-~/ii/content-engine}:/app/output - ./.env:/app/.env:ro diff --git a/package.json b/package.json index 5db64e4..dba371f 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "docker:x:midday": "docker compose run --rm --no-deps autopost node code/posting/run-x-slot.js midday", "docker:x:evening": "docker compose run --rm --no-deps autopost node code/posting/run-x-slot.js evening", "docker:tiktok:morning": "docker compose run --rm --no-deps autopost node code/posting/run-tiktok-slot.js morning", - "docker:tiktok:evening": "docker compose run --rm --no-deps autopost node code/posting/run-tiktok-slot.js evening" + "docker:tiktok:evening": "docker compose run --rm --no-deps autopost node code/posting/run-tiktok-slot.js evening", + "test": "node --test tests/*.test.js" }, "dependencies": { "axios": "^1.13.4", diff --git a/prompts/carousel-templates.json b/prompts/carousel-templates.json index da024f7..a81f291 100644 --- a/prompts/carousel-templates.json +++ b/prompts/carousel-templates.json @@ -1,3 +1,25 @@ { - "templates": [] + "templates": [ + { + "id": "seasonal-sweets-how-to", + "label": "Seasonal Sweets How-To", + "description": "Step-by-step dessert carousel for a cozy seasonal treat with one clear action per slide, realistic food visuals, and a save-forward ending.", + "content_angle": "Promise-first recipe explainer with ingredients, one action per card, a strong final result image, and a CTA that encourages saves before a holiday baking session.", + "hook_examples": [ + "How to make peanut butter Easter eggs", + "Save this Easter candy tutorial", + "A cozy homemade Easter treat" + ], + "slide_examples": [ + "How to make peanut butter Easter eggs", + "Mix the creamy peanut butter filling", + "Shape, chill, dip, and save" + ], + "caption_examples": [ + "This carousel breaks the recipe into clean, save-worthy steps.", + "Use one action per card so the viewer never has to guess what happens next.", + "Keep the wording literal and searchable so the post performs as both a tutorial and a reference." + ] + } + ] } diff --git a/prompts/video-templates.json b/prompts/video-templates.json index 6845348..aee0d38 100644 --- a/prompts/video-templates.json +++ b/prompts/video-templates.json @@ -1110,6 +1110,458 @@ "dialogue_examples": [ "\"ah.\"" ] + }, + { + "id": "bathroom-renovation-before-after-promo", + "label": "Bathroom Renovation Before/After Promo", + "description": "Photorealistic single-clip bathroom transformation promo built around a continuity-locked interior frame, realistic construction-grade design details, and smartphone-style camera naturalism.", + "contract_version": 1, + "rule_profile": "promo", + "caption_profile": "promo", + "prompting_profile": "grok_bathroom_renovation_before_after_promo", + "clip_count": 1, + "clip_duration_seconds": 6, + "target_length_seconds": 6, + "reference_strategy": "shared_reference", + "aspect_ratio": "9:16", + "resolution": "720p", + "image_model": "grok-imagine-image", + "video_model": "grok-imagine-video", + "format_hint": "single-shot photoreal bathroom renovation reveal for a construction company, starting from an ordinary dated bathroom and landing on a premium remodeled after state with believable iPhone-style handheld camera realism", + "ordering_strategy": "establish the dated bathroom, push forward through the remodel reveal, land on the finished premium after state", + "voice_direction": "default to no dialogue, no cinematic trailer language, no glossy commercial polish, and no impossible camera behavior. The clip should feel like a real homeowner or contractor filmed the reveal on an iPhone in available light.", + "research_notes": [ + "Photoreal results improve when the prompt specifies ordinary smartphone framing, practical interior lighting, and believable material imperfections instead of generic hyperreal adjectives alone.", + "Before/after transformation prompts work better when the before state and after state are described with concrete fixtures, finishes, and lighting changes rather than abstract quality words.", + "For realism, preserve one locked room layout across the transformation: same vanity wall, same toilet placement, same shower footprint, and the same camera axis.", + "A subtle handheld step-in or doorway push feels more like an iPhone capture than a floating cinematic move.", + "Avoid over-stylized lens terms; emphasize natural exposure shifts, slight handheld micro-jitter, realistic white balance, and everyday renovation details." + ], + "creative_direction": [ + "Use a small, believable residential bathroom rather than a huge luxury spa set.", + "Make the before state clearly older and worn but still physically plausible: dated vanity, yellowish light, old tile, cramped styling, minor clutter.", + "Make the after state premium but grounded: bright neutral tile, frameless glass shower, matte black fixtures, floating vanity, clean mirror lighting, realistic reflections.", + "Keep materials honest and tactile with grout lines, subtle scuffs, natural reflections, and non-perfect symmetry." + ], + "workflow_contract": { + "required_steps": [ + "author a single-beat promo markdown from saved repo state", + "generate one continuity-safe scene start frame that locks the bathroom layout and camera angle", + "animate one dominant before-to-after transformation beat with realistic handheld smartphone motion", + "save the research artifact, markdown, caption, asset manifest, plans, clips, and final output together in the run folder" + ], + "authoring_mode": "template-first saved artifacts", + "scene_generation_mode": "one scene card with image-first layout locking" + }, + "video_execution_contract": { + "builder_version": 1, + "lines": { + "preamble": [ + "Create a vertical 9:16 photoreal video clip.", + "Target duration: {{clip_duration_seconds}} seconds.", + "Treat the following image direction as the opening frame and room layout to preserve.", + "Continuity is more important than novelty or extra detail.", + "Keep the exact same bathroom layout, camera axis, and material logic established by the image direction and continuity anchors.", + "The camera should feel like a real recent iPhone handheld capture: natural exposure, subtle handheld micro-movement, realistic auto white-balance behavior, and no impossible floating drone motion.", + "Do not stylize the room into CGI showroom fantasy, ultra-wide architecture render distortion, or cinematic luxury-ad gloss." + ], + "image_direction_header": "IMAGE DIRECTION:", + "continuity_header": "CONTINUITY ANCHORS:", + "default_continuity_anchors": "Preserve the same bathroom layout, camera angle, and realism established by the image prompt.", + "motion_intro": "Animate that exact room using this reveal direction:", + "video_direction_header": "VIDEO DIRECTION:", + "speaker_named": "Only {{speaker}} may deliver the spoken line in this clip.", + "speaker_none": "If speech is not clearly required, prefer no dialogue.", + "speaker_lock_named": "Speaker lock: {{speaker}} is the only source allowed to deliver audible speech.", + "speaker_lock_none": "Prefer no speech over assigning a voice awkwardly.", + "silent_characters_named": "{{silent_characters}} stay silent and must not mouth, narrate, or appear to say the line.", + "silent_characters_none": "Any visible people stay silent unless the prompt explicitly says otherwise.", + "dialogue_named": "Use exactly this spoken line: \"{{dialogue}}\"", + "dialogue_none": "Use no spoken dialogue unless the motion direction explicitly requires it.", + "wrong_speaker_failsafe_named": "If the line cannot be cleanly assigned to {{speaker}}, output no audible dialogue instead.", + "wrong_speaker_failsafe_none": "Do not add random narration or a wrong speaking source.", + "staging_named": "Keep staging clear so {{speaker}} is the obvious readable source if speech occurs.", + "staging_none": "Keep the reveal visually clear and legible with no confusion about what changed in the room.", + "action_none": "Keep one dominant visible action.", + "ending": [ + "Keep the transformation and final room details clear on a phone screen.", + "Output a single finished video clip." + ] + } + }, + "authoring_sections": [ + { + "title": "Format Summary", + "instruction": "Describe the one-shot construction promo concept, what makes the before/after transition readable, and why the camera should feel like a real iPhone walkthrough instead of a glossy ad.", + "required": true, + "min_words": 35 + }, + { + "title": "Before Lock", + "instruction": "Define the dated bathroom before state with concrete details: vanity, mirror, tile, lighting, clutter level, proportions, and any imperfections that support realism.", + "required": true, + "min_words": 45 + }, + { + "title": "After Lock", + "instruction": "Define the remodeled after state with concrete construction details: vanity, shower, tile, fixtures, lighting, reflections, and finish quality. Keep it premium but plausible.", + "required": true, + "min_words": 45 + }, + { + "title": "Camera and Realism Lock", + "instruction": "Specify the smartphone-style camera feel, focal behavior, handheld motion, lighting realism, exposure behavior, and the layout elements that must stay locked through the transformation.", + "required": true, + "min_words": 45 + }, + { + "title": "Asset Plan", + "instruction": "Record the scene start frame and any other references needed before rendering. Explain how the opening frame locks the room layout so the before/after transition remains believable.", + "required": true, + "min_words": 35 + } + ], + "asset_contract": { + "requires_character_assets": false, + "requires_scene_start_frames": true, + "requires_saved_run_artifacts": true + }, + "cast_contract": { + "requires_named_cast": false, + "requires_locked_wardrobe": false, + "requires_role_assignment": false, + "minimum_cast_size": 0, + "role_blueprint": [] + }, + "scene_contract": { + "default_story_beats": [ + "single before-to-after reveal" + ], + "scene_prompt_strategy": "the clip should open on a continuity-locked dated bathroom and transform within the same camera move into a finished remodel with realistic smartphone movement and believable material upgrades" + }, + "continuity_contract": { + "requires_continuity_anchors": true, + "preferred_reference_strategy": "shared_reference", + "priority_order": [ + "room layout", + "camera axis and framing", + "vanity and shower placement", + "material realism and reflections", + "lighting direction and white balance", + "overall smartphone-captured look" + ] + }, + "video_examples": [ + "The shot should feel like someone standing in the doorway and stepping in slightly as the dated bathroom upgrades into the finished remodel.", + "The reveal should preserve the same sink wall, toilet position, and shower footprint so the transformation reads as one real renovation.", + "The final second should hold long enough for viewers to read the upgraded vanity, tile, mirror lighting, and frameless shower glass." + ], + "dialogue_examples": [ + "\"\"" + ] + }, + { + "id": "bathroom-pov-walkthrough-promo", + "label": "Bathroom POV Walkthrough Promo", + "description": "Photorealistic POV walkthrough of a middle-income household bathroom, built around one locked residential layout, smartphone-style camera movement, and continuity-safe before or after renovation states.", + "contract_version": 1, + "rule_profile": "promo", + "caption_profile": "promo", + "prompting_profile": "grok_bathroom_pov_walkthrough_promo", + "clip_count": 1, + "clip_duration_seconds": 10, + "target_length_seconds": 10, + "reference_strategy": "shared_reference", + "aspect_ratio": "9:16", + "resolution": "720p", + "image_model": "grok-imagine-image", + "video_model": "grok-imagine-video", + "format_hint": "single-shot photoreal POV walkthrough of a realistic middle-income household bathroom, captured like a homeowner or contractor walking in with an iPhone and slowly showing the space", + "ordering_strategy": "enter from the doorway, move through one natural smartphone walkthrough beat, and hold on the key bathroom features long enough for the room to read", + "voice_direction": "default to no dialogue, no narration, and no glossy luxury-ad polish. The camera should feel like a real person holding an iPhone and looking around a normal home bathroom.", + "research_notes": [ + "POV realism improves when the prompt specifies natural body-driven camera bob, small stabilization corrections, and ordinary smartphone framing instead of cinematic camera jargon.", + "Middle-income bathroom realism comes from believable scale, practical fixtures, common finish choices, and mild imperfections rather than showroom-level perfection.", + "For continuity, keep the same doorway angle, vanity wall, toilet placement, and shower or tub footprint across all related shots.", + "A short pause on the vanity and shower area helps the room read clearly on mobile without requiring dramatic camera moves.", + "Subtle natural exposure changes and realistic mirror or tile reflections help the clip feel captured on a phone rather than rendered." + ], + "creative_direction": [ + "Keep the bathroom compact and plausible, like a normal suburban or urban middle-income home.", + "Use ordinary materials and fixtures that feel current or dated depending on the state, but never exaggerated.", + "Favor human-height POV motion with restrained handheld movement over sweeping cinematic motion.", + "Keep the result clean and polished enough for a construction company promo while still feeling authentic." + ], + "workflow_contract": { + "required_steps": [ + "author a single-beat POV walkthrough markdown from saved repo state", + "generate or reuse one continuity-safe scene start frame that locks the bathroom layout and camera angle", + "animate one realistic smartphone walkthrough beat from that locked frame", + "save the research artifact, markdown, caption, asset manifest, plans, clips, and final output together in the run folder" + ], + "authoring_mode": "template-first saved artifacts", + "scene_generation_mode": "one scene card with image-first layout locking" + }, + "video_execution_contract": { + "builder_version": 1, + "lines": { + "preamble": [ + "Create a vertical 9:16 photoreal video clip.", + "Target duration: {{clip_duration_seconds}} seconds.", + "Treat the following image direction as the opening frame and room layout to preserve.", + "Continuity is more important than novelty or extra detail.", + "Keep the exact same bathroom layout, camera axis, material scale, and overall realism established by the image direction and continuity anchors.", + "The camera should feel like a real iPhone POV walkthrough with natural handheld body motion, subtle stabilization corrections, and believable exposure changes.", + "Do not turn this into a cinematic architecture reel, drone move, or glossy CGI showroom render." + ], + "image_direction_header": "IMAGE DIRECTION:", + "continuity_header": "CONTINUITY ANCHORS:", + "default_continuity_anchors": "Preserve the same bathroom layout, camera angle, and smartphone realism established by the image prompt.", + "motion_intro": "Animate that exact bathroom using this POV walkthrough direction:", + "video_direction_header": "VIDEO DIRECTION:", + "speaker_named": "Only {{speaker}} may deliver the spoken line in this clip.", + "speaker_none": "If speech is not clearly required, prefer no dialogue.", + "speaker_lock_named": "Speaker lock: {{speaker}} is the only source allowed to deliver audible speech.", + "speaker_lock_none": "Prefer no speech over assigning a voice awkwardly.", + "silent_characters_named": "{{silent_characters}} stay silent and must not mouth, narrate, or appear to say the line.", + "silent_characters_none": "Any visible people stay silent unless the prompt explicitly says otherwise.", + "dialogue_named": "Use exactly this spoken line: \"{{dialogue}}\"", + "dialogue_none": "Use no spoken dialogue unless the motion direction explicitly requires it.", + "wrong_speaker_failsafe_named": "If the line cannot be cleanly assigned to {{speaker}}, output no audible dialogue instead.", + "wrong_speaker_failsafe_none": "Do not add random narration or a wrong speaking source.", + "staging_named": "Keep staging clear so {{speaker}} is the obvious readable source if speech occurs.", + "staging_none": "Keep the walkthrough visually clear so the room layout and featured upgrades are easy to read.", + "action_none": "Keep one dominant visible action.", + "ending": [ + "Keep the room details legible on a phone screen.", + "Output a single finished video clip." + ] + } + }, + "authoring_sections": [ + { + "title": "Format Summary", + "instruction": "Describe the one-shot POV walkthrough, the bathroom state being shown, and why the shot should feel like a real iPhone capture inside a middle-income home instead of a glossy ad.", + "required": true, + "min_words": 35 + }, + { + "title": "Bathroom State Lock", + "instruction": "Define the bathroom exactly as it appears in this run, including vanity, mirror, toilet, shower or tub, tile, lighting, scale, clutter level, and any material imperfections that support realism.", + "required": true, + "min_words": 55 + }, + { + "title": "Camera and Realism Lock", + "instruction": "Specify the POV camera feel, lens behavior, handheld motion, natural exposure shifts, and the layout elements that must stay fixed through the walkthrough.", + "required": true, + "min_words": 45 + }, + { + "title": "Continuity Sources", + "instruction": "Record which reference frame or prior run establishes the room layout, and explain how this run preserves the same doorway angle, vanity wall, toilet placement, and shower footprint.", + "required": true, + "min_words": 35 + }, + { + "title": "Asset Plan", + "instruction": "Record the scene start frame and any references needed before rendering. Note whether the frame is reused directly or generated from another saved bathroom reference.", + "required": true, + "min_words": 30 + } + ], + "asset_contract": { + "requires_character_assets": false, + "requires_scene_start_frames": true, + "requires_saved_run_artifacts": true + }, + "cast_contract": { + "requires_named_cast": false, + "requires_locked_wardrobe": false, + "requires_role_assignment": false, + "minimum_cast_size": 0, + "role_blueprint": [] + }, + "scene_contract": { + "default_story_beats": [ + "single POV walkthrough" + ], + "scene_prompt_strategy": "the clip should open on a continuity-locked bathroom frame and animate one realistic human-height walkthrough beat with smartphone motion and clear room readability" + }, + "continuity_contract": { + "requires_continuity_anchors": true, + "preferred_reference_strategy": "shared_reference", + "priority_order": [ + "room layout", + "camera axis and entry path", + "vanity and shower placement", + "fixture scale and material realism", + "lighting direction and white balance", + "overall smartphone-captured look" + ] + }, + "video_examples": [ + "The camera should feel like someone stepping into the doorway and slowly looking through the bathroom, not like a floating virtual camera.", + "The vanity, toilet, and shower area should all remain easy to read throughout the clip, even with slight handheld motion.", + "The final second should settle enough for the viewer to understand the room state clearly." + ], + "dialogue_examples": [ + "\"\"" + ] + }, + { + "id": "neon-code-learning-montage", + "label": "Neon Code Learning Montage", + "description": "Six-beat futuristic coding explainer built around a consistent neon city world, AI-assisted development visuals, Git workflow beats, and continuity-safe stitched clips.", + "contract_version": 1, + "rule_profile": "promo", + "caption_profile": "promo", + "prompting_profile": "grok_neon_code_learning_montage", + "clip_count": 6, + "clip_duration_seconds": 6, + "target_length_seconds": 36, + "reference_strategy": "shared_reference", + "aspect_ratio": "9:16", + "resolution": "720p", + "image_model": "grok-imagine-image", + "video_model": "grok-imagine-video", + "format_hint": "six stitched futuristic explainer beats about learning to code with AI and Git, set inside a grounded cyberpunk neon-city world with high-clarity motion and mobile-readable composition", + "ordering_strategy": "hook, AI coding setup, first build, git save point, collaboration and push, futuristic payoff", + "voice_direction": "default to no dialogue or extremely sparse literal lines. Let the visuals, interfaces, and motion sell the idea. Prioritize clarity, continuity, and a cool but believable sci-fi feel over flashy chaos.", + "research_notes": [ + "For cyberpunk or neon-city AI video, forward-only or logically progressive camera motion reduces artifacting and helps transitions feel intentional.", + "Scene-start frames and end-state locking are more reliable than asking one raw generation to invent a complex futuristic transition from scratch.", + "Futuristic coding visuals stay more readable when interface elements are implied through light, holographic panels, and hand motion rather than dense fake text blocks.", + "Continuity across stitched clips improves when the same protagonist silhouette, workstation palette, city-light grade, and environmental anchors persist between beats.", + "Educational coding montages work better when each beat has one dominant action: prompt AI, type code, save changes, branch, commit, push, review, or deploy." + ], + "creative_direction": [ + "Keep the world cool and futuristic, but grounded enough to feel like a believable near-future city rather than abstract VFX noise.", + "Use neon city reflections, holographic UI glow, volumetric light, and sci-fi overlays sparingly enough that code-learning actions remain legible on a phone screen.", + "Favor crisp forward motion, practical handheld or shoulder-height camera logic, and one dominant beat per clip.", + "Make Git feel visual through branching lines, glowing commit nodes, code panes, terminal light, and collaborative handoffs rather than text-heavy screens." + ], + "workflow_contract": { + "required_steps": [ + "author a six-beat coding-learning markdown from saved repo state", + "generate one continuity-safe scene start frame per clip to lock world design and beat composition", + "animate each beat as a short futuristic coding or Git action", + "stitch clips, burn captions if needed, and save the final run artifacts" + ], + "authoring_mode": "template-first saved artifacts", + "scene_generation_mode": "one scene card per clip with image-first continuity locking" + }, + "video_execution_contract": { + "builder_version": 1, + "lines": { + "preamble": [ + "Create a vertical 9:16 photoreal or high-end cinematic video clip.", + "Target duration: {{clip_duration_seconds}} seconds.", + "Treat the following image direction as the opening frame and world identity to preserve.", + "Continuity is more important than novelty or extra detail.", + "Keep the exact same protagonist design, workstation logic, city-light palette, environment identity, and overall realism established by the image direction and continuity anchors.", + "The camera should feel like a deliberate near-future explainer shot with logical forward motion, readable subject framing, and controlled sci-fi effects.", + "Do not turn this into random glitch chaos, unreadable fake UI spam, or an incoherent music-video montage." + ], + "image_direction_header": "IMAGE DIRECTION:", + "continuity_header": "CONTINUITY ANCHORS:", + "default_continuity_anchors": "Preserve the same protagonist, world design, workstation identity, and overall futuristic realism established by the image prompt.", + "motion_intro": "Animate that exact scene using this coding and motion direction:", + "video_direction_header": "VIDEO DIRECTION:", + "speaker_named": "Only {{speaker}} may deliver the spoken line in this clip.", + "speaker_none": "If speech is not clearly required, prefer no dialogue.", + "speaker_lock_named": "Speaker lock: {{speaker}} is the only source allowed to deliver audible speech.", + "speaker_lock_none": "Prefer no speech over assigning a voice awkwardly.", + "silent_characters_named": "{{silent_characters}} stay silent and must not mouth, narrate, or appear to say the line.", + "silent_characters_none": "Any visible people stay silent unless the prompt explicitly says otherwise.", + "dialogue_named": "Use exactly this spoken line: \"{{dialogue}}\"", + "dialogue_none": "Use no spoken dialogue unless the motion direction explicitly requires it.", + "wrong_speaker_failsafe_named": "If the line cannot be cleanly assigned to {{speaker}}, output no audible dialogue instead.", + "wrong_speaker_failsafe_none": "Do not add random narration or a wrong speaking source.", + "staging_named": "Keep staging clear so {{speaker}} is the obvious readable source if speech occurs.", + "staging_none": "Keep the coding action, Git action, or interface beat visually clear and easy to read.", + "action_none": "Keep one dominant visible action.", + "ending": [ + "Keep the scene legible on a phone screen.", + "Output a single finished video clip." + ] + } + }, + "authoring_sections": [ + { + "title": "Format Summary", + "instruction": "Describe the full six-beat montage, the educational angle, and why the result should feel like a crisp futuristic explainer instead of a vague cyberpunk mood reel.", + "required": true, + "min_words": 45 + }, + { + "title": "World Lock", + "instruction": "Define the neon city, workstation style, lighting palette, UI treatment, and sci-fi effects that stay consistent across the stitched clips.", + "required": true, + "min_words": 55 + }, + { + "title": "Learner Lock", + "instruction": "Define the recurring coder or learner identity, body language, wardrobe, and how the person interacts with AI and Git across the six beats.", + "required": true, + "min_words": 45 + }, + { + "title": "Sequence Logic", + "instruction": "Explain how the six clips connect, what each beat teaches, and how camera motion or transitions remain logically progressive across the montage.", + "required": true, + "min_words": 45 + }, + { + "title": "Asset Plan", + "instruction": "Record the scene start frames and any references needed before rendering. Note whether continuity is carried by shared world design, repeated protagonist identity, or specific saved frames.", + "required": true, + "min_words": 35 + } + ], + "asset_contract": { + "requires_character_assets": false, + "requires_scene_start_frames": true, + "requires_saved_run_artifacts": true + }, + "cast_contract": { + "requires_named_cast": false, + "requires_locked_wardrobe": true, + "requires_role_assignment": false, + "minimum_cast_size": 1, + "role_blueprint": [] + }, + "scene_contract": { + "default_story_beats": [ + "future city hook", + "ai coding setup", + "first code build", + "git save point", + "branch commit push", + "futuristic payoff" + ], + "scene_prompt_strategy": "each clip should open on a continuity-locked futuristic frame and animate one clear coding or Git action with readable neon-city atmosphere" + }, + "continuity_contract": { + "requires_continuity_anchors": true, + "preferred_reference_strategy": "shared_reference", + "priority_order": [ + "protagonist identity", + "workstation and tool language", + "city-light palette and world style", + "camera logic and transition readability", + "UI and code-learning clarity", + "overall futuristic realism" + ] + }, + "video_examples": [ + "A strong beat should feel like a futuristic coding action, not an abstract VFX loop.", + "The protagonist, workstation, and city atmosphere should feel like the same world across all six clips.", + "Neon effects should support readability, not bury the educational idea." + ], + "dialogue_examples": [ + "\"\"" + ] } ] } diff --git a/tests/autopost-regressions.test.js b/tests/autopost-regressions.test.js new file mode 100644 index 0000000..65560a1 --- /dev/null +++ b/tests/autopost-regressions.test.js @@ -0,0 +1,182 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import sharp from 'sharp'; + +import { processSlideshow } from '../code/crop-for-instagram.js'; +import { II_ROOT, SCHEDULED_CAROUSELS_DIR } from '../code/core/paths.js'; +import { postToX, selectImages } from '../code/posting/post-to-x.js'; +import { findPostedStatusUrl, hasTransientComposerError } from '../code/posting/x-browser-post.js'; +import { listScheduledItems, resolvePostOutroPath } from '../code/shared/scheduled-queue.js'; + +function makeTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +async function writeJpeg(filePath, width, height, color = '#7744aa') { + const svg = Buffer.from(` + + + test + + `); + + await sharp(svg).jpeg({ quality: 80 }).toFile(filePath); +} + +test('processSlideshow handles slides that are already 4:5', async () => { + const tempDir = makeTempDir('content-engine-crop-'); + + try { + await writeJpeg(path.join(tempDir, 'slide_1.jpg'), 1080, 1350); + fs.writeFileSync(path.join(tempDir, 'metadata.json'), JSON.stringify({ + topic: 'Already 4:5', + slides: [ + { + slide_number: 1, + text_position: 'top', + text_overlay: 'Hook', + }, + ], + }, null, 2)); + + await processSlideshow(tempDir); + + const outputPath = path.join(tempDir, 'instagram', 'slide_1.jpg'); + assert.equal(fs.existsSync(outputPath), true); + + const meta = await sharp(outputPath).metadata(); + assert.equal(meta.width, 1080); + assert.equal(meta.height, 1350); + + const instagramMeta = JSON.parse(fs.readFileSync(path.join(tempDir, 'instagram', 'metadata.json'), 'utf8')); + assert.equal(instagramMeta.slides[0].instagram_crop.top, 0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('selectImages falls back to original slides when instagram render exists but is empty', async () => { + const tempDir = makeTempDir('content-engine-x-images-'); + + try { + fs.mkdirSync(path.join(tempDir, 'instagram'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'metadata.json'), JSON.stringify({ + slides: [ + { slide_number: 1, slide_type: 'hook' }, + { slide_number: 2, slide_type: 'tutorial_step' }, + { slide_number: 3, slide_type: 'tutorial_step' }, + { slide_number: 4, slide_type: 'tutorial_step' }, + { slide_number: 5, slide_type: 'cta' }, + ], + }, null, 2)); + + for (let index = 1; index <= 5; index += 1) { + await writeJpeg(path.join(tempDir, `slide_${index}.jpg`), 1080, 1920, '#335577'); + } + + const selected = selectImages(tempDir); + assert.equal(selected.length, 4); + assert.equal(selected.every(filePath => !filePath.includes('/instagram/')), true); + assert.equal(selected[0].endsWith('slide_1.jpg'), true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('listScheduledItems skips malformed schedule manifests instead of aborting the run', () => { + const validId = `test-valid-${Date.now()}`; + const invalidId = `test-invalid-${Date.now()}`; + const validDir = path.join(SCHEDULED_CAROUSELS_DIR, validId); + const invalidDir = path.join(SCHEDULED_CAROUSELS_DIR, invalidId); + + fs.mkdirSync(validDir, { recursive: true }); + fs.mkdirSync(invalidDir, { recursive: true }); + + try { + fs.writeFileSync(path.join(validDir, 'schedule.json'), JSON.stringify({ + id: validId, + type: 'carousel', + scheduled_at: new Date().toISOString(), + source: { folder: 'output/carousels/test' }, + assets: { + folder_path: 'output/scheduled_carousels/test', + metadata_path: 'output/scheduled_carousels/test/metadata.json', + slide_files: ['slide_1.jpg'], + }, + posts: { instagram: null, x: null }, + }, null, 2)); + fs.writeFileSync(path.join(invalidDir, 'schedule.json'), '{'); + + const items = listScheduledItems('carousel'); + const ids = new Set(items.map(item => item.manifest.id)); + + assert.equal(ids.has(validId), true); + assert.equal(ids.has(invalidId), false); + } finally { + fs.rmSync(validDir, { recursive: true, force: true }); + fs.rmSync(invalidDir, { recursive: true, force: true }); + } +}); + +test('resolvePostOutroPath keeps relative overrides rooted inside II_ROOT', () => { + const resolved = resolvePostOutroPath({ + post_defaults: { + outro_path: 'assets/plug.mov', + }, + }); + + assert.equal(resolved, path.join(II_ROOT, 'assets/plug.mov')); +}); + +test('hasTransientComposerError detects the X composer retry banner', () => { + assert.equal( + hasTransientComposerError('Something went wrong, but don’t fret — let’s give it another shot.'), + true, + ); + assert.equal(hasTransientComposerError('All clear.'), false); +}); + +test('findPostedStatusUrl uses a fresh lookup page instead of navigating the composer page', async () => { + let composerUrl = 'https://x.com/compose/post'; + let lookupClosed = false; + let gotoUrl = ''; + + const lookupPage = { + async goto(url) { + gotoUrl = url; + }, + async waitForTimeout() {}, + async evaluate() { + return 'https://x.com/videogens/status/123'; + }, + async close() { + lookupClosed = true; + }, + }; + + const page = { + url() { + return composerUrl; + }, + context() { + return { + async newPage() { + return lookupPage; + }, + }; + }, + }; + + const result = await findPostedStatusUrl(page, 'videogens', 'hello world'); + assert.equal(result, 'https://x.com/videogens/status/123'); + assert.equal(gotoUrl, 'https://x.com/videogens'); + assert.equal(page.url(), composerUrl); + assert.equal(lookupClosed, true); +}); + +test('postToX is exported for runtime use', () => { + assert.equal(typeof postToX, 'function'); +});