Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions Dockerfile.autopost
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 1 addition & 3 deletions code/carousel/generate-slideshow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 21 additions & 3 deletions code/core/paths.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
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');
export const SCHEDULED_CAROUSELS_DIR = path.join(OUTPUT_DIR, 'scheduled_carousels');
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');

Expand Down
97 changes: 75 additions & 22 deletions code/crop-for-instagram.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand All @@ -61,26 +63,61 @@ 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
* @param {string} outputPath - Path for cropped output
* @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);

Expand All @@ -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;
Expand Down Expand Up @@ -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`);
}

Expand All @@ -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,
Expand Down
9 changes: 5 additions & 4 deletions code/debug/grok-submit-probe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 5 additions & 6 deletions code/posting/auto-post-instagram-ai-video.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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)`);
Expand Down
28 changes: 25 additions & 3 deletions code/posting/auto-post-instagram.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading