diff --git a/.env.example b/.env.example index ea85dcdf..221ceafe 100644 --- a/.env.example +++ b/.env.example @@ -314,13 +314,15 @@ ANALYTICS_PROVIDER=composio # vary by toolkit version, so they are NOT guessed — set the ones you have # verified for your account. Unset operations are reported as unavailable # (never fabricated). Pattern: COMPOSIO___ACTION -# operations: PUBLISH_POST, UPLOAD_MEDIA, POST_INSIGHTS, AD_INSIGHTS, +# operations: PUBLISH_POST, PUBLISH_VIDEO, UPLOAD_MEDIA, POST_INSIGHTS, AD_INSIGHTS, # ACCOUNT_INSIGHTS, CREATE_AD, LIST_AD_ACCOUNTS, LIST_PAGES, ACCOUNT_INFO # Examples (verify slugs against your Composio toolkit version before setting): # COMPOSIO_FACEBOOK_PUBLISH_POST_ACTION=FACEBOOK_CREATE_POST +# COMPOSIO_FACEBOOK_PUBLISH_VIDEO_ACTION=FACEBOOK_CREATE_VIDEO_POST # COMPOSIO_INSTAGRAM_PUBLISH_POST_ACTION=INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH # COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION=FACEBOOK_CREATE_PHOTO_POST # COMPOSIO_INSTAGRAM_UPLOAD_MEDIA_ACTION=INSTAGRAM_POST_IG_USER_MEDIA +# COMPOSIO_INSTAGRAM_ACCOUNT_INFO_ACTION=INSTAGRAM_GET_USER_INFO # COMPOSIO_META_ADS_CREATE_AD_ACTION=META_ADS_CREATE_AD # X (Twitter) publish (#631) — only consulted when ARIES_X_ENABLED=true and # PUBLISH_PROVIDER=composio/auto. UPLOAD_MEDIA stages the image (file_uploadable) @@ -334,9 +336,11 @@ ANALYTICS_PROVIDER=composio # passthrough for subreddits that require a post flair. # COMPOSIO_REDDIT_PUBLISH_POST_ACTION=REDDIT_CREATE_REDDIT_POST COMPOSIO_FACEBOOK_PUBLISH_POST_ACTION= +COMPOSIO_FACEBOOK_PUBLISH_VIDEO_ACTION= COMPOSIO_INSTAGRAM_PUBLISH_POST_ACTION= COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION= COMPOSIO_INSTAGRAM_UPLOAD_MEDIA_ACTION= +COMPOSIO_INSTAGRAM_ACCOUNT_INFO_ACTION= COMPOSIO_X_PUBLISH_POST_ACTION= COMPOSIO_X_UPLOAD_MEDIA_ACTION= COMPOSIO_REDDIT_PUBLISH_POST_ACTION= diff --git a/app/api/internal/publishing/scheduled-dispatch/route.ts b/app/api/internal/publishing/scheduled-dispatch/route.ts index 1b3068c4..09462ad7 100644 --- a/app/api/internal/publishing/scheduled-dispatch/route.ts +++ b/app/api/internal/publishing/scheduled-dispatch/route.ts @@ -20,6 +20,9 @@ type ScheduledDispatchBody = { media_urls?: string[]; surface?: string; media_type?: string; + width_px?: number | null; + height_px?: number | null; + duration_seconds?: number | null; }; // Minimal queryable surface so route tests can inject a fake DB. @@ -198,6 +201,18 @@ export async function POST(req: Request): Promise { ? 'video' : 'image'; + // Per-media dimensions/duration forwarded from scheduled_posts (populated by a + // later ingest/synthesize step; NULL today). Build mediaMetadata ONLY for a + // video surface with all three present — never fabricate (the validator fails + // closed on missing video metadata, which is the intended behavior). + const widthPx = typeof body.width_px === 'number' && Number.isFinite(body.width_px) ? body.width_px : null; + const heightPx = typeof body.height_px === 'number' && Number.isFinite(body.height_px) ? body.height_px : null; + const durationSeconds = typeof body.duration_seconds === 'number' && Number.isFinite(body.duration_seconds) ? body.duration_seconds : null; + const mediaMetadata: Array<{ widthPx: number; heightPx: number; durationSeconds: number }> | undefined = + mediaType === 'video' && widthPx !== null && heightPx !== null && durationSeconds !== null + ? [{ widthPx, heightPx, durationSeconds }] + : undefined; + // Prefer explicit media_urls, otherwise look up creative assets for the tenant let rawMediaUrls: string[] = Array.isArray(body.media_urls) ? body.media_urls.filter((u): u is string => typeof u === 'string' && u.trim().length > 0) @@ -281,6 +296,7 @@ export async function POST(req: Request): Promise { mediaUrls: signedMediaUrls, placement: surface, mediaType, + mediaMetadata, }); results.push({ provider: platform, ok: true }); if (firstPublishedPostId === null && published.platformPostId) { diff --git a/app/api/social-content/jobs/[jobId]/posts/[postId]/schedule/route.ts b/app/api/social-content/jobs/[jobId]/posts/[postId]/schedule/route.ts index ff8cd1a7..e1ea74cc 100644 --- a/app/api/social-content/jobs/[jobId]/posts/[postId]/schedule/route.ts +++ b/app/api/social-content/jobs/[jobId]/posts/[postId]/schedule/route.ts @@ -174,7 +174,7 @@ export async function handlePatchScheduleSocialContentPost( try { const lookup = await client.query( - 'SELECT id, tenant_id, surface, media_type FROM posts WHERE id = $1 AND tenant_id = $2 LIMIT 1', + 'SELECT id, tenant_id, surface, media_type, width_px, height_px, duration_seconds FROM posts WHERE id = $1 AND tenant_id = $2 LIMIT 1', [postIdInt, tenantId], ); if ((lookup.rowCount ?? lookup.rows.length) === 0 || lookup.rows.length === 0) { @@ -185,15 +185,27 @@ export async function handlePatchScheduleSocialContentPost( }); return NextResponse.json(POST_NOT_FOUND, { status: 404 }); } - // The post's own surface/media_type are authoritative — mirror them onto the - // scheduled_posts row so an image story (or reel) dispatches on the right - // Meta surface instead of defaulting to 'feed'. - const postRow = lookup.rows[0] as { surface?: unknown; media_type?: unknown } | undefined; + // The post's own surface/media_type/dims are authoritative — mirror them onto + // the scheduled_posts row so an image story (or reel) dispatches on the right + // Meta surface instead of defaulting to 'feed', and validateMediaForSurface + // has real width/height/duration_seconds at dispatch time. + const postRow = lookup.rows[0] as { + surface?: unknown; + media_type?: unknown; + width_px?: unknown; + height_px?: unknown; + duration_seconds?: unknown; + } | undefined; const postSurfaceRaw = typeof postRow?.surface === 'string' ? postRow.surface.trim().toLowerCase() : ''; const postSurface: 'feed' | 'story' | 'reel' = postSurfaceRaw === 'story' || postSurfaceRaw === 'reel' ? postSurfaceRaw : 'feed'; const postMediaType: 'image' | 'video' = typeof postRow?.media_type === 'string' && postRow.media_type.trim().toLowerCase() === 'video' ? 'video' : 'image'; + const toDimPx = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const postWidthPx = toDimPx(postRow?.width_px); + const postHeightPx = toDimPx(postRow?.height_px); + const postDurationSeconds = toDimPx(postRow?.duration_seconds); // Publish-approval gate: a post may only be queued for auto-publish once a // human has approved the publish stage. Mirrors the marketing publish path @@ -225,6 +237,9 @@ export async function handlePatchScheduleSocialContentPost( campaignEndDate, surface: postSurface, mediaType: postMediaType, + widthPx: postWidthPx, + heightPx: postHeightPx, + durationSeconds: postDurationSeconds, }); scheduleScheduledPostHonchoWrite({ diff --git a/backend/integrations/composio/composio-config.ts b/backend/integrations/composio/composio-config.ts index f7cf35c3..891f60b6 100644 --- a/backend/integrations/composio/composio-config.ts +++ b/backend/integrations/composio/composio-config.ts @@ -38,6 +38,7 @@ export const TOOLKIT_SLUG: Record = { export type ComposioOperation = | 'publish_post' + | 'publish_video' | 'upload_media' | 'post_insights' | 'ad_insights' diff --git a/backend/integrations/composio/composio-publisher-provider.ts b/backend/integrations/composio/composio-publisher-provider.ts index e8eed2f9..2a53674a 100644 --- a/backend/integrations/composio/composio-publisher-provider.ts +++ b/backend/integrations/composio/composio-publisher-provider.ts @@ -32,7 +32,15 @@ import { ComposioToolError, } from './errors'; import { resolveFacebookManagedPage } from './facebook-page-resolver'; +import { resolveInstagramAccount } from './instagram-account-resolver'; import { synthesizeStillToVideo, type StillToVideoResult } from '../still-to-video'; +import { MetaPublishError } from '../meta-publishing'; +import { + validateMediaForSurface, + type MediaMetadata, + type MediaSurface, + type MediaType, +} from '../meta-media-validation'; import pool from '@/lib/db'; function pickId(data: unknown, keys: string[]): string | null { @@ -183,6 +191,58 @@ export class ComposioPublisherProvider implements PublisherProvider { return slug; } + /** + * Per-surface media validation for a video/Story/Reel publish, run BEFORE any + * `gateway.executeTool` so nothing is ever posted on a malformed payload. + * + * SAFETY (double-post guard): `validateMediaForSurface` throws a + * `MetaPublishError`, which is NOT in `publishNeverReachedPlatform`'s recognized + * set (publish-outcome.ts). If that raw error escaped `publishPost`, + * `dispatchPublish`'s catch would fail the `publishNeverReachedPlatform` check + * and re-wrap it as `provider_publish_outcome_unknown` (`outcomeUnknown:true`) — + * surfacing a post that PROVABLY never reached the platform as + * needs_manual_reconciliation. Since validation runs before any tool call, the + * post definitely never posted, so we rethrow as `ComposioToolError` — a + * recognized never-posted verdict — exactly as the X/LinkedIn/YouTube + * pre-publish staging failures are surfaced. A validation failure is therefore + * unambiguously definitely-never-posted (safe to roll back the claim). + */ + private validateMediaSurfaceOrNeverPosted( + input: PublishPostInput, + surface: MediaSurface, + mediaType: MediaType, + slug: string, + ): void { + const media: MediaMetadata[] = input.mediaUrls.map((url, i) => ({ + url, + widthPx: input.mediaMetadata?.[i]?.widthPx ?? null, + heightPx: input.mediaMetadata?.[i]?.heightPx ?? null, + durationSeconds: input.mediaMetadata?.[i]?.durationSeconds ?? null, + })); + try { + validateMediaForSurface({ media, surface, mediaType, scheduledFor: input.scheduledFor ?? null }); + } catch (error) { + if (error instanceof MetaPublishError) { + throw new ComposioToolError(slug, `${error.code}: ${error.message}`); + } + throw error; + } + } + + /** + * The IG user id (numeric) the container/publish actions need as `ig_user_id`. + * `connected_accounts.external_account_id` is null for IG (it is not in the + * connection metadata), so fall back to the verified INSTAGRAM_GET_USER_INFO + * resolver, then to `'me'` (the actions accept the literal `'me'`). The resolver + * is a read-only pre-publish call that never creates a post. + */ + private async resolveInstagramUserId(connectedAccountId: string, externalAccountId: string | null): Promise { + const stored = externalAccountId?.trim(); + if (stored) return stored; + const resolved = await resolveInstagramAccount(this.gateway, this.config, connectedAccountId); + return resolved?.igUserId ?? 'me'; + } + async publishPost(input: PublishPostInput): Promise { // Dry-run never touches Composio. if (input.dryRun) { @@ -248,26 +308,48 @@ export class ComposioPublisherProvider implements PublisherProvider { ); } - const hasImage = input.mediaUrls.length > 0; - if (hasImage) { - // Photo post: FACEBOOK_CREATE_PHOTO_POST via the `upload_media` op slot - // (COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION=FACEBOOK_CREATE_PHOTO_POST). - // Only the first image is posted; multi-image carousel is a future feature. - slug = this.requireSlug(input.platform, 'upload_media', 'publish photo posts'); + if (input.mediaType === 'video') { + // Video post: FACEBOOK_CREATE_VIDEO_POST via the `publish_video` op slot + // (COMPOSIO_FACEBOOK_PUBLISH_VIDEO_ACTION). The raw mp4 file_url is posted + // directly — no pre-stage. Composio has NO distinct FB Reel/Story video + // action, so a reel/story video collapses to a Page (feed) video; validate + // it against feed-video constraints accordingly. + slug = this.requireSlug(input.platform, 'publish_video', 'publish videos'); + this.validateMediaSurfaceOrNeverPosted(input, 'feed', 'video', slug); toolArgs = { - url: input.mediaUrls[0], - message: input.content, page_id: pageId, - ...(input.scheduledFor ? { scheduled_publish_time: input.scheduledFor } : {}), + file_url: input.mediaUrls[0], + description: input.content, + // Scheduling requires `published:false` AND `scheduled_publish_time` + // TOGETHER (a `scheduled_publish_time` without `published:false` would + // publish immediately — the latent bug in the photo/text branch above + // is deliberately NOT copied here). + ...(input.scheduledFor + ? { published: false, scheduled_publish_time: input.scheduledFor } + : { published: true }), }; } else { - // Text-only post: FACEBOOK_CREATE_POST via the `publish_post` op slot. - slug = this.requireSlug(input.platform, 'publish_post', 'publish posts'); - toolArgs = { - message: input.content, - page_id: pageId, - ...(input.scheduledFor ? { scheduled_publish_time: input.scheduledFor } : {}), - }; + const hasImage = input.mediaUrls.length > 0; + if (hasImage) { + // Photo post: FACEBOOK_CREATE_PHOTO_POST via the `upload_media` op slot + // (COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION=FACEBOOK_CREATE_PHOTO_POST). + // Only the first image is posted; multi-image carousel is a future feature. + slug = this.requireSlug(input.platform, 'upload_media', 'publish photo posts'); + toolArgs = { + url: input.mediaUrls[0], + message: input.content, + page_id: pageId, + ...(input.scheduledFor ? { scheduled_publish_time: input.scheduledFor } : {}), + }; + } else { + // Text-only post: FACEBOOK_CREATE_POST via the `publish_post` op slot. + slug = this.requireSlug(input.platform, 'publish_post', 'publish posts'); + toolArgs = { + message: input.content, + page_id: pageId, + ...(input.scheduledFor ? { scheduled_publish_time: input.scheduledFor } : {}), + }; + } } } else if (input.platform === 'x') { // X (Twitter): a text post, optionally with a single image. Unlike @@ -508,15 +590,80 @@ export class ComposioPublisherProvider implements PublisherProvider { privacyStatus: youtubePrivacyStatus(), }; } else if (input.platform === 'instagram') { - // Instagram: caption + media_urls + placement + media_type (unchanged). - slug = this.requireSlug(input.platform, 'publish_post', 'publish posts'); - toolArgs = { + // Instagram: a TWO-STEP publish — create a media container, then publish it. + // 1. INSTAGRAM_POST_IG_USER_MEDIA (`upload_media` op) → creation_id + // 2. INSTAGRAM_POST_IG_USER_MEDIA_PUBLISH (`publish_post` op) → ig_media_id + // Image and video share the same container action; the surface selects the + // container's media_type. A single clean public URL is posted per call + // (multi-image carousel is a future feature). The previous single-call shape + // matched no real Composio action and never published live, so this is a + // ground-up rewrite, not a regression of working behaviour. + if (input.mediaUrls.length === 0) { + throw new ComposioCapabilityMissingError('instagram', 'publish a post — an image or video is required'); + } + const surface: MediaSurface = input.placement ?? 'feed'; + const isVideo = input.mediaType === 'video'; + + // Resolve BOTH slugs (capability-missing — definitely-never-posted — when unset). + const containerSlug = this.requireSlug(input.platform, 'upload_media', 'create a media container'); + const publishSlug = this.requireSlug(input.platform, 'publish_post', 'publish posts'); + + // Fail-closed media validation BEFORE the container is created — nothing is + // posted on a malformed payload and a validation failure is never-posted. + // An IG feed VIDEO publishes as a REELS container, which Meta requires to be + // vertical 9:16, so validate it against reel constraints (not the laxer feed + // rules) — catch a non-9:16 clip at Aries rather than at the Meta container. + const validationSurface: MediaSurface = isVideo && surface === 'feed' ? 'reel' : surface; + this.validateMediaSurfaceOrNeverPosted(input, validationSurface, isVideo ? 'video' : 'image', containerSlug); + + const igUserId = await this.resolveInstagramUserId(conn.connectedAccountId!, conn.externalAccountId ?? null); + + // ── Step 1: create the media container (single clean public URL) ────── + const containerArgs: Record = { + ig_user_id: igUserId, caption: input.content, - media_urls: input.mediaUrls, - placement: input.placement ?? 'feed', - media_type: input.mediaType ?? 'image', - ...(input.scheduledFor ? { scheduled_publish_time: input.scheduledFor } : {}), }; + if (isVideo) { + containerArgs.video_url = input.mediaUrls[0]; + // surface → IG container media_type. reel → REELS; story → STORIES; + // feed video → REELS + share_to_feed (a Reel that also lands in the feed). + if (surface === 'story') { + containerArgs.media_type = 'STORIES'; + } else { + containerArgs.media_type = 'REELS'; + if (surface === 'feed') containerArgs.share_to_feed = true; + } + } else { + // Feed image: image_url, no media_type (IG defaults to a single IMAGE). + containerArgs.image_url = input.mediaUrls[0]; + } + + const container = await this.gateway.executeTool(containerSlug, { + connectedAccountId: conn.connectedAccountId!, + arguments: containerArgs, + }); + if (!container.successful) { + // The broker explicitly rejected the container — no container, no post. + throw new ComposioToolError(containerSlug, container.error ?? 'media container create reported unsuccessful'); + } + const creationId = pickId(container.data, ['id', 'creation_id']); + if (!creationId) { + throw new ComposioToolError(containerSlug, 'media container create returned no creation id'); + } + + // ── Step 2: publish the container (executed by the shared call below) ── + // This is the ONLY outcome-unknown boundary for IG: a transport drop after + // this call may have published. A failed container above is never-posted. + slug = publishSlug; + toolArgs = { + ig_user_id: igUserId, + creation_id: creationId, + // Bounded server-side poll for the container to finish processing (<=300). + max_wait_seconds: 300, + poll_interval_seconds: 5, + }; + // The published media id comes back as ig_media_id (or id). + idKeys = ['ig_media_id', 'id', 'media_id']; } else { // Unknown / unhandled platform — refuse explicitly rather than silently // falling through to an Instagram payload on the wrong network. diff --git a/backend/integrations/direct/direct-meta-provider.ts b/backend/integrations/direct/direct-meta-provider.ts index 02d6ba54..8cf35e22 100644 --- a/backend/integrations/direct/direct-meta-provider.ts +++ b/backend/integrations/direct/direct-meta-provider.ts @@ -113,6 +113,7 @@ export class DirectMetaProvider mediaUrls: input.mediaUrls, placement, mediaType, + mediaMetadata: input.mediaMetadata, scheduledFor: input.scheduledFor ?? null, }); diff --git a/backend/integrations/providers/types.ts b/backend/integrations/providers/types.ts index 921f5782..9d62394e 100644 --- a/backend/integrations/providers/types.ts +++ b/backend/integrations/providers/types.ts @@ -174,6 +174,12 @@ export interface PublishPostInput { * live post when this is not true. */ approved?: boolean; + /** + * Optional positional per-media width/height/duration (provider-agnostic), + * aligned to mediaUrls. Mirror of MetaPublishRequest.mediaMetadata; forwarded + * to the publisher so video surfaces can be validated. NULL/undefined today. + */ + mediaMetadata?: Array<{ widthPx?: number | null; heightPx?: number | null; durationSeconds?: number | null }>; } export interface PublishAdInput { diff --git a/backend/integrations/publish-dispatch.ts b/backend/integrations/publish-dispatch.ts index ab5449ad..c95f4b93 100644 --- a/backend/integrations/publish-dispatch.ts +++ b/backend/integrations/publish-dispatch.ts @@ -113,6 +113,7 @@ export async function dispatchPublish( mediaUrls: request.mediaUrls, placement: request.placement, mediaType: request.mediaType, + mediaMetadata: request.mediaMetadata, scheduledFor: request.scheduledFor ?? null, // The handlers only dispatch already-approved posts; the seam still enforces // its own guard, so make the cleared approval explicit. diff --git a/backend/marketing/auto-schedule.ts b/backend/marketing/auto-schedule.ts index d92f6fac..7881d95c 100644 --- a/backend/marketing/auto-schedule.ts +++ b/backend/marketing/auto-schedule.ts @@ -138,6 +138,10 @@ export interface AutoScheduleInputRow { surface?: AutoScheduleSurface; /** Media type (image|video), mirrored onto scheduled_posts. Default 'image'. */ mediaType?: 'image' | 'video'; + /** Per-media video dims mirrored onto scheduled_posts. NULL today. */ + widthPx?: number | null; + heightPx?: number | null; + durationSeconds?: number | null; /** Hermes's free-form time hint, currently unused — recorded for future override hooks. */ recommendedTimeWindow?: string | null; } @@ -161,6 +165,10 @@ export interface AutoScheduleSlot { surface: AutoScheduleSurface; /** Media type carried through to the scheduled_posts upsert. */ mediaType: 'image' | 'video'; + /** Per-media video dims carried through to the scheduled_posts upsert. NULL today. */ + widthPx: number | null; + heightPx: number | null; + durationSeconds: number | null; /** Derived UTC instant ready for `scheduled_posts.scheduled_for`. */ scheduledFor: Date; /** Audit trail: which weekday name we used (e.g. "Monday" or "fallback: first day in window"). */ @@ -263,6 +271,9 @@ export function computeAutoScheduleSlots(input: ComputeAutoScheduleSlotsInput): platform: row.platform, surface, mediaType, + widthPx: row.widthPx ?? null, + heightPx: row.heightPx ?? null, + durationSeconds: row.durationSeconds ?? null, scheduledFor: utc, appliedDay, appliedWallTime: wallTimeIso, @@ -393,6 +404,9 @@ export async function autoSchedulePosts( platforms: [slot.platform], surface: slot.surface, mediaType: slot.mediaType, + widthPx: slot.widthPx, + heightPx: slot.heightPx, + durationSeconds: slot.durationSeconds, campaignEndDate: input.campaignEnd, }); scheduled += 1; diff --git a/backend/marketing/hermes-callbacks.ts b/backend/marketing/hermes-callbacks.ts index a1ec2df6..47ea3280 100644 --- a/backend/marketing/hermes-callbacks.ts +++ b/backend/marketing/hermes-callbacks.ts @@ -1424,7 +1424,7 @@ async function autoScheduleApprovedPostsForJob(doc: SocialContentJobRuntimeDocum // than collapsing onto its feed sibling's slot. ORDER BY id is added for // stable logging output even though the ordinal mapping does not depend on it. const postRows = await pool.query( - `SELECT id, platform, idempotency_key, surface, media_type + `SELECT id, platform, idempotency_key, surface, media_type, width_px, height_px, duration_seconds FROM posts WHERE job_id = $1 AND tenant_id = $2 ORDER BY id`, @@ -1464,6 +1464,9 @@ export interface AutoSchedulePostRow { idempotency_key: string | null; surface: string | null; media_type: string | null; + width_px: number | null; + height_px: number | null; + duration_seconds: number | null; } /** @@ -1536,6 +1539,9 @@ export function buildAutoScheduleRows( recommendedDay: target?.recommendedDay ?? null, surface: normalizeScheduleSurface(row.surface), mediaType: normalizeScheduleMediaType(row.media_type), + widthPx: row.width_px, + heightPx: row.height_px, + durationSeconds: row.duration_seconds, }; }) .filter((r): r is AutoScheduleInputRow => r !== null); diff --git a/backend/marketing/ingest-production-assets.ts b/backend/marketing/ingest-production-assets.ts index 02481590..dd7d8edb 100644 --- a/backend/marketing/ingest-production-assets.ts +++ b/backend/marketing/ingest-production-assets.ts @@ -109,6 +109,12 @@ type CreativeAssetEntry = { path?: string; prompt?: string; placement?: string; + media_type?: string; + surface?: string; + width?: number; + height?: number; + duration_seconds?: number; + mime?: string; [key: string]: unknown; }; @@ -117,7 +123,10 @@ type CreativeAssetEntry = { // exclude video and story/reel/carousel placements. function isFrameEligibleEntry(asset: CreativeAssetEntry): boolean { const type = typeof asset.type === 'string' ? asset.type.trim().toLowerCase() : ''; - if (type === 'video') return false; + const mediaType = typeof asset.media_type === 'string' ? asset.media_type.trim().toLowerCase() : ''; + // Never composite a logo onto video (the new contract emits type + // 'generated_video' / media_type 'video'; legacy emitted type 'video'). + if (type === 'video' || type === 'generated_video' || mediaType === 'video') return false; const placement = typeof asset.placement === 'string' ? asset.placement.trim().toLowerCase() : ''; return placement === '' || placement === 'feed'; } @@ -171,20 +180,25 @@ export function resolveHermesAssetReadPath(reportedPath: string): string | null // 0 rows and the existing row keeps its ref — replay stays idempotent. The // partial unique index (WHERE checksum IS NOT NULL) is named so null-checksum // rows never collide. This mirrors story-composer.ts's INSERT_COMPOSED_ASSET_SQL. +// Params: $1 tenantId, $2 jobId, $3 sourceAssetId, $4 storageKey, $5 checksum, +// $6 variantBatchId, $7 variantIndex, $8 storageKind, +// $9 mediaType, $10 aspectRatio, $11 widthPx, $12 heightPx, $13 durationSeconds const INSERT_PRODUCTION_ASSET_SQL = ` INSERT INTO creative_assets ( id, tenant_id, source_type, source_job_id, source_asset_id, storage_kind, storage_key, media_type, aspect_ratio, checksum, permission_scope, learning_lifecycle, usable_for_generation, - variant_batch_id, variant_index, served_asset_ref + variant_batch_id, variant_index, served_asset_ref, + width_px, height_px, duration_seconds ) SELECT g.id, $1, 'generated_by_aries', $2, $3, - $8, $4, 'image', - '4:5', $5, 'generated', + $8, $4, $9, + $10, $5, 'generated', 'observed', false, - $6, $7, '/api/internal/hermes/media/' || g.id::text + $6, $7, '/api/internal/hermes/media/' || g.id::text, + $11, $12, $13 FROM (SELECT gen_random_uuid() AS id) g ON CONFLICT (tenant_id, checksum) WHERE checksum IS NOT NULL DO NOTHING RETURNING id @@ -321,17 +335,52 @@ export async function ingestProductionCreativeAssetsToDb( ? asset.assetId.trim() : basename; + // Derive media type: 'video' when the entry carries video markers. + const isVideo = + asset.type === 'generated_video' || + (typeof asset.media_type === 'string' && asset.media_type.trim().toLowerCase() === 'video'); + const mediaType: 'image' | 'video' = isVideo ? 'video' : 'image'; + + // Derive aspect ratio from width/height when present; else from surface/placement. + const entryWidth = typeof asset.width === 'number' && Number.isFinite(asset.width) ? asset.width : null; + const entryHeight = typeof asset.height === 'number' && Number.isFinite(asset.height) ? asset.height : null; + const entryDuration = + typeof asset.duration_seconds === 'number' && Number.isFinite(asset.duration_seconds) + ? asset.duration_seconds + : null; + + let aspectRatio: string; + if (entryWidth !== null && entryHeight !== null && entryWidth > 0 && entryHeight > 0) { + // Reduce to a simplified ratio string; map to nearest known where reasonable. + if (entryHeight > entryWidth) { + aspectRatio = '9:16'; + } else { + aspectRatio = '4:5'; + } + } else { + // Infer from surface / placement. + const surface = typeof asset.surface === 'string' ? asset.surface.trim().toLowerCase() : ''; + const placement = typeof asset.placement === 'string' ? asset.placement.trim().toLowerCase() : ''; + const isVertical = surface === 'reel' || surface === 'story' || placement === 'reel' || placement === 'story'; + aspectRatio = isVertical ? '9:16' : '4:5'; + } + // served_asset_ref is built inside the INSERT from the row's own (subselect- // generated) id, so it is not passed as a parameter here. const result = await pool.query(INSERT_PRODUCTION_ASSET_SQL, [ - tenantId, - jobId, - sourceAssetId, - storageKey, - checksum, - variantBatchId, - variantIndex, - storageKind, + tenantId, // $1 + jobId, // $2 + sourceAssetId, // $3 + storageKey, // $4 + checksum, // $5 + variantBatchId, // $6 + variantIndex, // $7 + storageKind, // $8 + mediaType, // $9 + aspectRatio, // $10 + entryWidth, // $11 + entryHeight, // $12 + entryDuration, // $13 ]); const rowCount = result.rowCount ?? 0; diff --git a/backend/marketing/ports/hermes.ts b/backend/marketing/ports/hermes.ts index 575190b9..75e49461 100644 --- a/backend/marketing/ports/hermes.ts +++ b/backend/marketing/ports/hermes.ts @@ -245,6 +245,13 @@ const LAST30DAYS_GUIDANCE = [ const PRODUCTION_EXECUTION_CONTRACT = 'PRODUCTION STAGE EXECUTION CONTRACT: When the input contains "Production context (N images requested)", you MUST return BOTH content_package[] AND artifacts.creative_assets[]. One without the other is incomplete and will fail downstream publish. (A) Call the `image_generate` tool exactly once per image listed — do not return JSON until every image_generate call has completed. (B) Build content_package[] with one entry per post: {post_number, theme, hook, body, cta, hashtags (array of 3-6 relevant hashtags), platforms, format, visual_prompt}. The Nth creative_asset corresponds to the Nth content_package post via post_number. content_package carries the post COPY (caption text, hooks, hashtags). creative_assets carries the rendered IMAGES. Return output:[{stage:"production", content_package:[{post_number:1, theme:"...", hook:"...", body:"...", cta:"...", hashtags:["#tag1","#tag2","#tag3"], platforms:["instagram","facebook"], format:"single_image", visual_prompt:"..."},...], artifacts:{creative_assets:[{assetId:"img_1", type:"generated_image", path:, prompt:, placement:}, ...], errors:[]}}]. If image_generate returns success:false for an item, record it in artifacts.errors[] and continue.'; +/** Instructs the production agent how to return video clips alongside images. + * Kept as a named export so workflow-request.ts can append it to the resume + * context block without re-stating the schema (single source of truth). + */ +export const VIDEO_EXECUTION_CONTRACT = + 'VIDEO CLIP RETURN CONTRACT: If the input contains "Video clip N of M requested", produce one 9:16 vertical video per requested clip. Return each clip in artifacts.creative_assets[] alongside the image assets — do NOT skip failed clips, record them in artifacts.errors[] instead (resumability rule). Each video entry in creative_assets MUST include: {assetId:"vid_1", type:"generated_video", media_type:"video", surface:"reel"|"story"|"feed", path:"", width:, height:, duration_seconds:, mime:"video/mp4", aspect_ratio:"9:16"}. Missing or null width/height/duration_seconds will fail closed at dispatch and the clip will not publish. Record render failures in artifacts.errors[] and continue.'; + /** * Per-stage instruction builders for the weekly social-content pipeline. * @@ -282,6 +289,7 @@ function buildWeeklyProductionInstructions(workflowKey: string): string { 'You are the Aries content-generation agent. You run ONLY the production stage of the weekly social content pipeline.', 'Your job is to generate images and post copy. The input carries per-image prompt context and the strategy output.', PRODUCTION_EXECUTION_CONTRACT, + VIDEO_EXECUTION_CONTRACT, 'After completing the production stage, return status "requires_approval" with approval.stage="publish", approval.approval_step="approve_publish", approval.workflowStepId="approve_stage_4", approval.prompt="Review creative assets before publish review", approval.resumeToken set, and output:[{stage:"production", ...artifacts}].', `Required schema: {"ok":true,"status":"requires_approval","workflowKey":"${workflowKey}","approval":{"stage":"publish","approval_step":"approve_publish","workflowStepId":"approve_stage_4","prompt":"...","resumeToken":"..."},"output":[{"stage":"production", ...}]}.`, ].join(' '); diff --git a/backend/marketing/synthesize-publish-posts.ts b/backend/marketing/synthesize-publish-posts.ts index 3141006d..8800288e 100644 --- a/backend/marketing/synthesize-publish-posts.ts +++ b/backend/marketing/synthesize-publish-posts.ts @@ -207,7 +207,7 @@ function readRequestedStoryCount(doc: SocialContentJobRuntimeDocument): number { } const SELECT_CREATIVE_ASSETS_SQL = ` - SELECT id, source_asset_id + SELECT id, source_asset_id, width_px, height_px, duration_seconds FROM creative_assets WHERE tenant_id = $1 AND source_job_id = $2 @@ -220,15 +220,19 @@ const SELECT_CREATIVE_ASSETS_SQL = ` // calendar's unscheduled-approved backlog query (`published_status='approved' // OR status='approved'`) and are schedulable. See the module header for why // approved (not draft) is correct for this autonomous-mode deployment. +// Params: $1 tenantId, $2 jobId, $3 publishRunId, $4 platform, +// $5 caption, $6 idempotencyKey, $7 creativeAssetIds, +// $8 mediaType, $9 surface, $10 styleDimension, $11 styleValue, +// $12 widthPx, $13 heightPx, $14 durationSeconds const INSERT_SYNTHESIZED_POST_SQL = ` INSERT INTO posts ( tenant_id, job_id, hermes_run_id, platform, media_type, caption, status, published_status, idempotency_key, creative_asset_ids, surface, - style_dimension, style_value + style_dimension, style_value, width_px, height_px, duration_seconds ) VALUES ( $1, $2, $3, $4, $8, $5, 'approved', 'approved', $6, $7, $9, - $10, $11 + $10, $11, $12, $13, $14 ) ON CONFLICT (tenant_id, platform, idempotency_key) WHERE idempotency_key IS NOT NULL DO NOTHING @@ -450,17 +454,26 @@ export async function synthesizePublishPostsFromContentPackage( // Pull the ingested creative_assets so each post can be linked to its image. // post_number N (1-indexed) maps to the Nth creative asset in source_asset_id // order — the same ordering ingestProductionCreativeAssetsToDb preserves. - let assetIdsByPostNumber = new Map(); + // Dims (width_px/height_px/duration_seconds) are threaded into posts rows so + // validateMediaForSurface has real metadata at dispatch time. + type AssetInfo = { assetId: string; widthPx: number | null; heightPx: number | null; durationSeconds: number | null }; + let assetInfoByPostNumber = new Map(); try { const result = await pool.query(SELECT_CREATIVE_ASSETS_SQL, [tenantId, jobId]); const rows = (result.rows ?? []) as Array>; - assetIdsByPostNumber = new Map( + assetInfoByPostNumber = new Map( rows.map((row, index) => { const assetId = typeof row.source_asset_id === 'string' && row.source_asset_id.trim() ? row.source_asset_id.trim() : String(row.id ?? ''); - return [index + 1, assetId] as const; + const widthPx = typeof row.width_px === 'number' && Number.isFinite(row.width_px) ? row.width_px : null; + const heightPx = typeof row.height_px === 'number' && Number.isFinite(row.height_px) ? row.height_px : null; + const durationSeconds = + typeof row.duration_seconds === 'number' && Number.isFinite(row.duration_seconds) + ? row.duration_seconds + : null; + return [index + 1, { assetId, widthPx, heightPx, durationSeconds }] as const; }), ); } catch (err) { @@ -489,7 +502,8 @@ export async function synthesizePublishPostsFromContentPackage( let total = 0; for (const entry of entries) { - const assetId = assetIdsByPostNumber.get(entry.postNumber); + const assetInfo = assetInfoByPostNumber.get(entry.postNumber); + const assetId = assetInfo?.assetId; const creativeAssetIds = assetId ? [assetId] : []; for (const platform of entry.platforms) { // Resolve the publish shape (surface + media_type) for this post/platform @@ -513,17 +527,20 @@ export async function synthesizePublishPostsFromContentPackage( const idempotencyKey = `${jobId}:${entry.postNumber}:${platform}:${shape.surface}`; try { const result = await pool.query(INSERT_SYNTHESIZED_POST_SQL, [ - tenantId, - jobId, - publishRunId, - platform, - entry.caption, - idempotencyKey, - creativeAssetIds, - shape.mediaType, - shape.surface, - styleDimension, - styleValue, + tenantId, // $1 + jobId, // $2 + publishRunId, // $3 + platform, // $4 + entry.caption, // $5 + idempotencyKey, // $6 + creativeAssetIds, // $7 + shape.mediaType, // $8 + shape.surface, // $9 + styleDimension, // $10 + styleValue, // $11 + assetInfo?.widthPx ?? null, // $12 + assetInfo?.heightPx ?? null, // $13 + assetInfo?.durationSeconds ?? null, // $14 ]); if ((result.rowCount ?? 0) > 0) { inserted++; @@ -563,7 +580,8 @@ export async function synthesizePublishPostsFromContentPackage( const storyBudget = readRequestedStoryCount(doc); if (storyBudget > 0) { for (const entry of entries.slice(0, storyBudget)) { - const assetId = assetIdsByPostNumber.get(entry.postNumber); + const assetInfo = assetInfoByPostNumber.get(entry.postNumber); + const assetId = assetInfo?.assetId; // A story is single-media with no text fallback. Skip entries with no // linked creative rather than emit a media-less story that would fail at // publish (publishInstagram requires >= 1 media url). @@ -590,17 +608,20 @@ export async function synthesizePublishPostsFromContentPackage( const idempotencyKey = `${jobId}:${entry.postNumber}:${platform}:story`; try { const result = await pool.query(INSERT_SYNTHESIZED_POST_SQL, [ - tenantId, - jobId, - publishRunId, - platform, - entry.caption, - idempotencyKey, - storyAssetIds, - 'image', - 'story', - styleDimension, - styleValue, + tenantId, // $1 + jobId, // $2 + publishRunId, // $3 + platform, // $4 + entry.caption, // $5 + idempotencyKey, // $6 + storyAssetIds, // $7 + 'image', // $8 media_type (story images are always image type) + 'story', // $9 surface + styleDimension, // $10 + styleValue, // $11 + null, // $12 width_px (composed story image — dims not carried from base asset) + null, // $13 height_px + null, // $14 duration_seconds ]); if ((result.rowCount ?? 0) > 0) { inserted++; diff --git a/backend/marketing/workspace-views.ts b/backend/marketing/workspace-views.ts index f7e5856b..8b12ca47 100644 --- a/backend/marketing/workspace-views.ts +++ b/backend/marketing/workspace-views.ts @@ -1356,7 +1356,8 @@ function syncWorkspaceReviewsFromRuntime( } const SELECT_PRODUCTION_CREATIVE_ASSETS_SQL = ` - SELECT id, source_asset_id, served_asset_ref, checksum + SELECT id, source_asset_id, served_asset_ref, checksum, + media_type, aspect_ratio, width_px, height_px, duration_seconds FROM creative_assets WHERE tenant_id = $1 AND source_job_id = $2 @@ -1370,6 +1371,11 @@ type ProductionCreativeAssetRow = { source_asset_id: string | null; served_asset_ref: string | null; checksum: string | null; + media_type: string | null; + aspect_ratio: string | null; + width_px: number | null; + height_px: number | null; + duration_seconds: number | null; }; async function queryProductionCreativeAssets( @@ -1387,6 +1393,14 @@ async function queryProductionCreativeAssets( source_asset_id: typeof r.source_asset_id === 'string' ? r.source_asset_id : null, served_asset_ref: typeof r.served_asset_ref === 'string' ? r.served_asset_ref : null, checksum: typeof r.checksum === 'string' ? r.checksum : null, + media_type: typeof r.media_type === 'string' ? r.media_type : null, + aspect_ratio: typeof r.aspect_ratio === 'string' ? r.aspect_ratio : null, + width_px: typeof r.width_px === 'number' && Number.isFinite(r.width_px) ? r.width_px : null, + height_px: typeof r.height_px === 'number' && Number.isFinite(r.height_px) ? r.height_px : null, + duration_seconds: + typeof r.duration_seconds === 'number' && Number.isFinite(r.duration_seconds) + ? r.duration_seconds + : null, }; }); } catch (err) { @@ -1554,17 +1568,19 @@ export async function buildSocialContentWorkspaceView( for (const row of dbProductionAssets) { const assetId = row.source_asset_id ?? row.id; if (existingAssetIds.has(assetId)) continue; + const isVideo = row.media_type === 'video'; newDbAssets.push({ reviewId: `${jobId}::creative:${assetId}`, reviewType: 'creative' as const, assetId, - title: 'Generated Image', + title: isVideo ? 'Generated Video' : 'Generated Image', summary: 'Production creative generated by Hermes.', platformLabel: 'Meta', status: 'approved' as const, - contentType: 'image/png', + contentType: isVideo ? 'video/mp4' : 'image/png', previewUrl: row.served_asset_ref, fullPreviewUrl: row.served_asset_ref, + posterUrl: null, destinationUrl: null, notes: [], latestNote: null, diff --git a/backend/social-content/scheduled-posts.ts b/backend/social-content/scheduled-posts.ts index 9b27064f..fc992586 100644 --- a/backend/social-content/scheduled-posts.ts +++ b/backend/social-content/scheduled-posts.ts @@ -26,6 +26,10 @@ export interface UpsertScheduledPostInput { surface?: 'feed' | 'story' | 'reel'; /** Media type mirrored onto scheduled_posts (image|video). Default 'image'. */ mediaType?: 'image' | 'video'; + /** Per-media video dims mirrored onto scheduled_posts. NULL today. */ + widthPx?: number | null; + heightPx?: number | null; + durationSeconds?: number | null; /** * UTC instant when publishing must stop for this row's parent campaign. NULL * means "no end date" -- the legacy weekly_social_content behaviour. Set by @@ -50,14 +54,17 @@ export interface ScheduledPostRecord { // effect immediately; a row that goes from event_campaign back to weekly (rare, // future cancellation flow) correctly clears the end date. const UPSERT_SCHEDULED_POST_SQL = ` - INSERT INTO scheduled_posts (post_id, tenant_id, scheduled_for, target_platforms, campaign_end_date, surface, media_type, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, now()) + INSERT INTO scheduled_posts (post_id, tenant_id, scheduled_for, target_platforms, campaign_end_date, surface, media_type, width_px, height_px, duration_seconds, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, now()) ON CONFLICT (post_id) DO UPDATE SET scheduled_for = EXCLUDED.scheduled_for, target_platforms = EXCLUDED.target_platforms, campaign_end_date = EXCLUDED.campaign_end_date, surface = EXCLUDED.surface, media_type = EXCLUDED.media_type, + width_px = EXCLUDED.width_px, + height_px = EXCLUDED.height_px, + duration_seconds = EXCLUDED.duration_seconds, updated_at = now() WHERE scheduled_posts.tenant_id = EXCLUDED.tenant_id RETURNING id, post_id, tenant_id, scheduled_for, target_platforms, updated_at @@ -79,6 +86,9 @@ export async function upsertScheduledPost( input.campaignEndDate ? input.campaignEndDate.toISOString() : null, input.surface ?? 'feed', input.mediaType ?? 'image', + input.widthPx ?? null, + input.heightPx ?? null, + input.durationSeconds ?? null, ]); if ((result.rowCount ?? result.rows.length) === 0 || result.rows.length === 0) { // Tenant guard: WHERE clause prevented update; surface typed error so diff --git a/backend/social-content/workflow-request.ts b/backend/social-content/workflow-request.ts index 2619c4a6..cee0f557 100644 --- a/backend/social-content/workflow-request.ts +++ b/backend/social-content/workflow-request.ts @@ -262,12 +262,33 @@ export type ProductionResumeImagePrompt = { targetChannels: string[]; }; +export type ProductionResumeVideoPrompt = { + clipIndex: number; // 1-based + totalClips: number; + prompt: string; + aspectRatio: '9:16'; + targetDurationSeconds: number; +}; + export type ProductionResumeContext = { imagePrompts: ProductionResumeImagePrompt[]; + /** Non-empty only when ARIES_VIDEO_PUBLISH_ENABLED is on and videoClipCount > 0. */ + videoPrompts?: ProductionResumeVideoPrompt[]; /** Serialised block ready to append to the Hermes resume `input` string. */ contextBlock: string; }; +/** + * Gate for video clip production in the resume context. Mirrors the rollout + * switch used by synthesize-publish-posts.ts (ARIES_VIDEO_PUBLISH_ENABLED). + * Defense-in-depth: ensures video briefs are never emitted when the feature + * flag is OFF, even if videoRenderCount was misconfigured as non-zero. + */ +function isVideoPublishEnabledForContext(): boolean { + const raw = (process.env.ARIES_VIDEO_PUBLISH_ENABLED ?? '').trim().toLowerCase(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + /** * Builds per-image prompt context for the production-stage resume call. * @@ -374,6 +395,17 @@ export function buildProductionResumeContext(input: { ); const totalImages = Math.max(1, imageCreativeCount); + // Video clip count: gated by ARIES_VIDEO_PUBLISH_ENABLED (defense-in-depth so + // a rendered clip is never left stranded when the gate is OFF). Default is 0 + // per SOCIAL_CONTENT_DEFAULT_SCOPE, so the flag-OFF path is byte-identical. + const videoClipCount = isVideoPublishEnabledForContext() + ? clampCount( + req.videoRenderCount ?? req.renderVideoCount, + SOCIAL_CONTENT_DEFAULT_SCOPE.video_render_count, + MAX_VIDEO_RENDER_COUNT, + ) + : 0; + // --- channel aspect-ratio human hint --- const aspectHintByChannel: Record = { instagram: 'portrait 4:5', @@ -507,6 +539,70 @@ export function buildProductionResumeContext(input: { }); } + // --- per-video-clip prompt construction (gated by ARIES_VIDEO_PUBLISH_ENABLED) --- + // When videoClipCount === 0 (default, flag OFF) this block is entirely inert + // and produces no output — preserving byte-identical contextBlock behavior. + const videoPrompts: ProductionResumeVideoPrompt[] = []; + if (videoClipCount > 0) { + const videoAspectRatio = resolveSocialContentAspectRatio({ channel: primaryChannel, postType: 'video' }); + for (let i = 0; i < videoClipCount; i++) { + const clipIndex = i + 1; + const vlines: string[] = [ + `Generate a short-form vertical video for social media content.`, + `This is video clip ${clipIndex} of ${videoClipCount}, part of the same ${windowDays}-day weekly campaign.`, + ``, + `Brand: ${brandName}`, + ]; + if (offer) vlines.push(`Offer: ${offer}`); + if (effectiveBrandVoice) vlines.push(`Brand voice: ${effectiveBrandVoice.slice(0, 200)}`); + if (effectiveStyleVibe) vlines.push(`Style and vibe: ${effectiveStyleVibe}`); + if (palette.length > 0) vlines.push(`Brand palette: ${palette.join(', ')}`); + if (tasteAudienceLine) vlines.push(tasteAudienceLine); + if (brandMode === 'dark') { + const bg = brandBackground || '#050505'; + vlines.push( + `CRITICAL BRAND REQUIREMENT — this is a DARK-themed brand. The video MUST have a dark, near-black background (${bg}). Use the brand palette as glowing accents; avoid bright/white/light/studio backgrounds.`, + ); + } else if (brandMode === 'light') { + vlines.push( + `Brand theme: light. Render on a light background${brandBackground ? ` (${brandBackground})` : ''} consistent with the brand.`, + ); + } else if (brandBackground) { + vlines.push(`Brand background: ${brandBackground} — keep backgrounds consistent with this brand color.`); + } + if (brandLogoUrl) { + vlines.push( + `Brand logo: ${brandLogoUrl} — use the actual brand logo when a mark is shown; do NOT invent, redraw, or substitute a different logo.`, + ); + } + if (effectiveMustAvoid.length > 0) vlines.push(`Must avoid: ${effectiveMustAvoid.join(', ')}`); + + if (researchLines.length > 0) { + vlines.push(''); + vlines.push('Brand and audience research:'); + vlines.push(...researchLines); + } + + if (strategyLines.length > 0) { + vlines.push(''); + vlines.push('Creative strategy:'); + vlines.push(...strategyLines); + } + + vlines.push(''); + vlines.push(`Aspect ratio: ${videoAspectRatio} (vertical 9:16 — optimised for Reels and Stories).`); + vlines.push(`Target duration: ~15 seconds (must be between 3 and 90 seconds).`); + + videoPrompts.push({ + clipIndex, + totalClips: videoClipCount, + prompt: vlines.filter((l, i) => l !== '' || vlines[i - 1] !== '').join('\n'), + aspectRatio: '9:16', + targetDurationSeconds: 15, + }); + } + } + // --- serialised context block --- const contextLines: string[] = [ `Production context (${totalImages} image${totalImages === 1 ? '' : 's'} requested):`, @@ -515,6 +611,12 @@ export function buildProductionResumeContext(input: { contextLines.push(`--- Image ${img.imageIndex} of ${img.totalImages} ---`); contextLines.push(img.prompt); } + // Video clip prompts are emitted inline after image prompts so the agent sees + // all context in one block. Empty when videoClipCount === 0 (byte-identical). + for (const vp of videoPrompts) { + contextLines.push(`--- Video clip ${vp.clipIndex} of ${vp.totalClips} ---`); + contextLines.push(vp.prompt); + } contextLines.push(''); contextLines.push('Return your results in this EXACT JSON shape. You MUST include BOTH content_package[] AND artifacts.creative_assets[]. One without the other is incomplete — content_package carries the post COPY (caption text, hooks, hashtags); creative_assets carries the rendered IMAGES. The Nth creative_asset corresponds to the Nth content_package entry via post_number.'); contextLines.push(''); @@ -551,9 +653,29 @@ export function buildProductionResumeContext(input: { contextLines.push('}'); contextLines.push(''); contextLines.push('The bridge will also accept `artifacts.images[]` with `{index, status:"generated", filePath, prompt, intendedUse}`, but `creative_assets` is preferred.'); + // Video return schema — only emitted when videoClipCount > 0 so the + // contextBlock is byte-identical to today when video is OFF (default). + if (videoClipCount > 0) { + contextLines.push(''); + contextLines.push('Required: when you finish video generation, place each clip in artifacts.creative_assets[] alongside the images, with this shape:'); + contextLines.push('{'); + contextLines.push(' "assetId": "vid_1",'); + contextLines.push(' "type": "generated_video",'); + contextLines.push(' "media_type": "video",'); + contextLines.push(' "surface": "reel",'); + contextLines.push(' "path": "",'); + contextLines.push(' "width": 1080,'); + contextLines.push(' "height": 1920,'); + contextLines.push(' "duration_seconds": 15,'); + contextLines.push(' "mime": "video/mp4",'); + contextLines.push(' "aspect_ratio": "9:16"'); + contextLines.push('}'); + contextLines.push('MANDATORY: width, height, and duration_seconds must be present and numeric — absent or null fails dispatch. Record failed renders in artifacts.errors[] — never discard a completed clip.'); + } return { imagePrompts, + ...(videoPrompts.length > 0 ? { videoPrompts } : {}), contextBlock: contextLines.join('\n'), }; } diff --git a/docker-compose.yml b/docker-compose.yml index 3bdd9146..828f8219 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -233,6 +233,8 @@ services: COMPOSIO_INSTAGRAM_PUBLISH_POST_ACTION: ${COMPOSIO_INSTAGRAM_PUBLISH_POST_ACTION:-} COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION: ${COMPOSIO_FACEBOOK_UPLOAD_MEDIA_ACTION:-} COMPOSIO_INSTAGRAM_UPLOAD_MEDIA_ACTION: ${COMPOSIO_INSTAGRAM_UPLOAD_MEDIA_ACTION:-} + COMPOSIO_FACEBOOK_PUBLISH_VIDEO_ACTION: ${COMPOSIO_FACEBOOK_PUBLISH_VIDEO_ACTION:-} + COMPOSIO_INSTAGRAM_ACCOUNT_INFO_ACTION: ${COMPOSIO_INSTAGRAM_ACCOUNT_INFO_ACTION:-} # In-app feedback button — Google Sheet mirror. The feedback API runs in the # app process, so these live here only. Mirror activates only when # COMPOSIO_ENABLED=true AND all four are set in the prod host .env; otherwise diff --git a/docs/plans/2026-06-23-video-generation.md b/docs/plans/2026-06-23-video-generation.md new file mode 100644 index 00000000..7615777a --- /dev/null +++ b/docs/plans/2026-06-23-video-generation.md @@ -0,0 +1,143 @@ +# Video Generation — Hermes return contract + Aries-side wiring + +Date: 2026-06-23 +Companion to `docs/plans/2026-05-30-story-reel-video-publishing.md` (the publish epic). + +## Goal + +Close the loop so Aries can generate short-form vertical video (Reels / Stories / +feed video) for the weekly social-content pipeline and publish it to Instagram + +Facebook. The **publish** half (Meta Graph video branches + per-surface +validation) already existed behind `ARIES_VIDEO_PUBLISH_ENABLED`; this work builds +the missing **generation → ingest → synthesize → publish-metadata** half. + +Provider decision: **video is generated by Grok / xAI (Grok Imagine)**, invoked by +Hermes. Aries never calls Grok directly and never names the tool — Aries sends a +high-level "generate a 9:16 video for this post" instruction and Hermes routes to +its own video tool. Prod publish goes through **Composio** (a new FB/IG video +branch), consistent with the all-Composio prod policy. + +## The contract Hermes must honor (load-bearing — the whole feature rides on it) + +On the **production** stage, Hermes emits video assets in the **same** +`doc.stages.production.primary_output.artifacts.creative_assets[]` array as images, +extending the existing image shape `{assetId, type, path, prompt, placement}`: + +| field | type | required | notes | +|---|---|---|---| +| `assetId` | string | yes | stable id (becomes `creative_assets.source_asset_id`) | +| `type` | string | yes | **`"generated_video"`** (image is `"generated_image"`) | +| `media_type` | string | yes | **`"video"`** — drives the `creative_assets.media_type` column + all downstream validation | +| `surface` | string | yes | **`"reel"` \| `"story"` \| `"feed"`** — drives aspect/duration validation | +| `path` | string | yes | **basename** of an mp4 written into the Hermes image-cache mount (e.g. `"weekly_primary_reel_1.mp4"`); resolved via `/`. Must end `.mp4`/`.mov` | +| `prompt` | string | yes | generation prompt | +| `placement` | number\|string | yes | maps to the `content_package` `post_number` | +| `width` | integer | **yes** | pixels — `validateMediaForSurface` fails closed if missing | +| `height` | integer | **yes** | pixels — fails closed if missing | +| `duration_seconds` | number | **yes** | seconds — reel **3–90**, story **≤60**, feed **3–60**; fails closed if missing/≤0 | +| `mime` | string | recommended | `"video/mp4"` | +| `aspect_ratio` | string | optional | `"9:16"` for reel/story; Aries derives from width/height if omitted | +| `cover_path` | string | optional | basename of a poster JPG/PNG in the same mount → dashboard `