Vibecode SocialFaktory
track this build4 phases, 12 steps, beginner friendly0%The generation half is honest API glue: script writing is an LLM call and the video itself comes from hosted model APIs (fal.ai and friends), so a personal brief-to-video pipeline is very buildable · but you pay those per-generation model invoices yourself either way. The publishing half is the same wall every scheduler hits: TikTok, Instagram, and YouTube OAuth apps are review-gated for weeks, which is why SocialFaktory itself publishes through Postiz. The honest DIY is a generation script feeding a self-hosted Postiz, and what's left over is the tuned prompt pipelines, the QA pass, and someone else eating the platform-review pain.
You are building a lean indie version of SocialFaktory. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # SocialFaktory · indie build A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, Express and SQLite, one local page with a queue and a review screen | the queue is the app; a render takes minutes and nobody watches a spinner | | Script | One model call per brief returning a hook, a spoken script and a visual prompt | the prompt lives in a file so you can tune voice without touching code | | Render | A hosted video model through its asynchronous queue API, polled, never re-submitted blind | video generation is the expensive call and the one you must not repeat by accident | | Labelling | ffmpeg burns a visible label and a verification step re-checks the marking after every encode | a disclosure you did not verify after encoding is a disclosure you probably lost | | Publishing | Your own self-hosted Postiz instance over its API, never a platform API directly | the platform credentials, the review queues and the labelling fields already live there | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer and ffmpeg** · free - Why: Runs the pipeline and burns the visible label into the video. - Get it: nodejs.org for Node, your package manager for ffmpeg. - Verify: node --version and ffmpeg -version both print - [ ] **An API key for a text model** · pennies per script - Why: Writes the hook, script and visual prompt from the brief. - Get it: Create a key with the provider you prefer and set a spend limit in their console. - Verify: One brief returns three validated fields - [ ] **An API key for a hosted video model with an asynchronous queue** · billed per second of output; measure one render before planning a schedule - Why: This is the expensive call and the one that must never be made twice for the same script. - Get it: Create a key with a provider whose queue API returns a request identifier you can poll. - Verify: One submit returns a request id and one poll returns a finished file - [ ] **A self-hosted Postiz instance with your channels connected** · free software; your own hosting - Why: It holds the platform credentials and the labelling fields, and it is the reason this build does not need a single platform developer account. - Get it: Run Postiz yourself and connect the channels you post to, then create an API key in it. - Verify: A test post from this app appears in the Postiz queue - [ ] **Who appears in these videos, and your written evidence for each** · free - Why: Article 50 covers disclosure; a person's voice and face are a separate matter and the one that produces angry messages. - Get it: List the stock presenters you will use, and for any real person obtain and file written permission with a date. - Verify: Every presenters row of kind real_person has a consent reference and date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. - pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks) - prompt pipelines tuned per model, format, and tone - an automated QA pass before anything ships - multi-brand workspaces and a scheduling calendar - a flat bill · DIY pays every model invoice directly, and video models are not cheap If one of those is essential to you, that is the reason to keep paying for SocialFaktory, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SocialFaktory The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a brief-to-video pipeline that renders a short, labels it as synthetic, and hands it to a publisher you already run to replace SocialFaktory. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This build makes a video of a person who does not exist, gives them a voice, and puts it on a public feed. Two things follow, and neither is a setting. Disclosure is mandatory in the EU and has been since 2 August 2026, when Article 50 of the AI Act started to apply. A deployer who uses an AI system to generate or manipulate image, audio or video content constituting a deep fake must disclose that the content has been artificially generated or manipulated. Where the work is evidently artistic, creative, satirical or fictional, the obligation is narrowed to disclosing that such content exists, in a way that does not spoil the work. Providers of the generators have a parallel duty to mark outputs in a machine-readable format. Penalties for breaching Article 50 run to fifteen million euro or three percent of worldwide annual turnover. So: every render carries a visible on-screen label and whatever machine-readable marking the generator attached, and a render missing either one cannot reach the publish step at all. Check what the platforms you post to require as well, because their synthetic-media labelling fields are separate from this and are set at publish time. Second, a face and a voice belong to somebody. Do not clone a real person's voice or likeness without written permission you can produce later. Use the generator's own stock presenters, or people who have signed something, and record which in the database before anything renders. ### Stack (fixed, do not substitute) - Runtime: Node 22, Express and SQLite, one local page with a queue and a review screen · the queue is the app; a render takes minutes and nobody watches a spinner - Script: One model call per brief returning a hook, a spoken script and a visual prompt · the prompt lives in a file so you can tune voice without touching code - Render: A hosted video model through its asynchronous queue API, polled, never re-submitted blind · video generation is the expensive call and the one you must not repeat by accident - Labelling: ffmpeg burns a visible label and a verification step re-checks the marking after every encode · a disclosure you did not verify after encoding is a disclosure you probably lost - Publishing: Your own self-hosted Postiz instance over its API, never a platform API directly · the platform credentials, the review queues and the labelling fields already live there ### Data model (create this before Phase 1) - briefs(id, topic, tone, target_seconds, created_at, brand_id) - scripts(id, brief_id, hook, spoken, visual_prompt, model_id, prompt_sha, cost_minor, created_at) - renders(id, script_id, provider, model_id, provider_request_id, state, mp4_path, seconds, cost_minor, submitted_at, finished_at) · provider_request_id is written before the first poll, not after - presenters(id, kind, source, consent_ref, consent_granted_at, notes) · kind is stock or real_person, and a real person without a consent_ref cannot be selected - disclosures(render_id, visible_label, burnt_in_at, marking_present_at_download, marking_present_after_encode, checked_at) - publications(id, render_id, postiz_post_id, channel, scheduled_for, ai_flag_set, published_at, state) - No row reaches publications without a disclosures row whose two marking checks are recorded, and no render starts without a presenter row that carries its consent evidence. ### Phase 1 · A brief becomes a script you can read Build: A form taking topic, tone and target duration. One call using a system prompt kept in prompts/script.md, returning three named fields validated against a schema. Store the prompt hash and the cost. Every field stays editable by hand before anything renders. Trap: Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. Done when: A brief produces three stored fields and a recorded cost, tested with a stubbed provider. The script is editable and the edit is what renders, verified by changing it and checking the render request. The system prompt lives in a file and its hash is stored with each script. Nothing renders automatically from the script screen, asserted by review. Do not build yet: No video, no labelling, no publishing. ### Phase 2 · A render that is never paid for twice Build: Write the render row and mark it submitting, call the provider's queue endpoint, store the returned request id immediately, then poll on a backoff. On startup, any row holding a request id is polled rather than resubmitted. Download the file once and record the actual cost and duration. Trap: A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. Done when: A render row carries its provider request id before the first poll, tested by killing the process between submit and poll. Restarting resumes polling for every row with a request id and creates no new submission, tested. Each script has a submission counter and a configured cap that refuses a further submit. The recorded cost comes from the provider's own reported figure or from its published per-second rate applied to the returned duration, and is never zero. Do not build yet: No labelling, no consent register, no publishing. ### Phase 3 · The label, and keeping it through the encode Build: Burn a short visible label into the video with ffmpeg at a size that survives a phone screen. Before that encode, record whether the downloaded file carried the provider's machine-readable marking. After the encode, check again and store both answers. If the marking is gone, either re-attach it or mark the render as visible-label-only and say so in the review screen. Trap: Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. Done when: Every render row has both marking checks recorded with timestamps, and neither is allowed to be null before publication. The visible label is present in the encoded file, verified by extracting a frame and inspecting it. A render whose marking was lost during encoding is flagged in the review screen with the fact stated plainly. The label text is configurable but cannot be set to an empty string. Do not build yet: No consent register and no publishing yet. ### Phase 4 · Who is in this video, and did they agree Build: A presenters table listing the generator's stock presenters and any real person whose voice or likeness you have permission to use, each real-person row carrying a reference to the signed permission and its date. Selecting a presenter is required before render. The review screen shows the presenter and its consent evidence next to the video. Trap: Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. Done when: A render cannot be created without a presenter id, enforced by the schema. A presenter of kind real_person without a consent reference cannot be selected, tested by trying. The review screen displays the presenter kind and consent date beside the player. Deleting a consent record marks affected presenters unusable for future renders without altering the history of past ones. Do not build yet: No publishing and no spend ceiling yet. ### Phase 5 · Handing it to the publisher Build: On approval, post the file and caption to your self-hosted Postiz with a scheduled time and the synthetic-content flag its channel supports. Never call a platform API directly from this code. Track the month's model and render spend and stop submitting when the ceiling is reached. Export every brief, script, render, disclosure and publication as JSONL with the media paths. Trap: Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. Done when: Nothing publishes without an explicit approval click, asserted in the suite. The publish request carries the synthetic-content flag where the channel supports one, and refuses to publish where the channel requires one and it could not be set. No platform hostname appears anywhere in the codebase outside documentation, checked by a grep test. The monthly ceiling stops new submissions and leaves review and publishing working. Export round-trips into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Calling TikTok, Instagram or YouTube APIs from this code, because the publisher you already run holds those credentials and the review process behind them is weeks of somebody else's life. - Cloning a real voice or face from recordings you happen to have, which is the feature this build deliberately does not have and the one most likely to end badly. - Multi-brand workspaces, teams and approval chains, which are the collaboration product rather than the generation pipeline. - An automated quality pass that decides a video is good enough to post, because you are the review step and that is the point. ### README must contain - That EU transparency rules for synthetic media have applied since 2 August 2026 and what the visible label and machine-readable marking in this build are for, with the article cited and the date you checked it - That the platforms have their own synthetic-content labelling fields set at publish time, separate from the label burnt into the file - That a real person's voice or likeness needs written permission recorded in the presenters table before anything renders - What one finished video costs to generate, measured from real runs rather than estimated, and where the monthly ceiling lives - That publishing happens through your own Postiz instance and this code never talks to a platform directly ===== AGENTS.md ===== # Agent instructions · SocialFaktory indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Express and SQLite, one local page with a queue and a review screen, One model call per brief returning a hook, a spoken script and a visual prompt, A hosted video model through its asynchronous queue API, polled, never re-submitted blind, ffmpeg burns a visible label and a verification step re-checks the marking after every encode, Your own self-hosted Postiz instance over its API, never a platform API directly. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ===== BUILD_PLAN.md ===== # Build plan · SocialFaktory A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A brief becomes a script you can read One model call turns a topic and tone into a hook, a spoken script and a visual prompt, all stored and editable. ### Steps 1. Keep the system prompt in a file and hash it You will tune this weekly and need to know which version wrote what. Files: `prompts/script.md`, `src/script/generate.ts` 2. Validate the three fields before storing A missing hook is better as an error than as an empty caption. Files: `src/script/schema.ts`, `tests/script.test.ts` 3. Put a human between the script and the render The render is the expensive step and the script is free to fix. Files: `src/web/script.ejs` ### Done when - [ ] A brief yields hook, script and visual prompt with a cost - [ ] The stored script is editable and the edit is what renders - [ ] The prompt file hash is recorded per script - [ ] No automatic path from script to render ### Watch out - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. ## Phase 2 · A render that is never paid for twice Submitting to the video model records the request identifier before polling, and a restart resumes rather than resubmits. ### Steps 1. Store the request id before you poll That identifier is the only proof you already paid. Files: `src/render/submit.ts`, `tests/resume.test.ts` 2. Resume on startup, never resubmit A crash is not a reason to buy the clip again. Files: `src/render/resume.ts` 3. Cap submissions per script The runaway retry loop is the failure mode with a price tag. Files: `src/render/cap.ts` ### Done when - [ ] The request id is written before the first poll - [ ] Restart resumes and never resubmits - [ ] A submission cap refuses further attempts - [ ] Cost and duration are recorded per render ### Watch out - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. ## Phase 3 · The label, and keeping it through the encode Every finished video has a visible synthetic-content label and its machine-readable marking is checked twice. ### Steps 1. Check the downloaded file for the marking first You need a before to have a loss. Files: `src/label/marking.ts` 2. Burn the visible label, then check the marking again Re-encoding is where provenance quietly dies. Files: `src/label/burn.ts`, `tests/label.test.ts` 3. Refuse to publish a render with a null check Unknown is not the same as present. Files: `src/publish/gate.ts` ### Done when - [ ] Both marking checks are recorded and non-null - [ ] The visible label appears in an extracted frame - [ ] A lost marking is surfaced in the review screen - [ ] The label text cannot be empty ### Watch out - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. ## Phase 4 · Who is in this video, and did they agree A presenter is chosen from a register, and a real person cannot be used without recorded consent. ### Steps 1. Make the presenter a row, not a phrase in a prompt A foreign key can be checked; a sentence cannot. Files: `src/db/migrations/003_presenters.sql` 2. Block rather than warn on missing consent A warning is a thing people click through at eleven at night. Files: `src/render/presenter.ts`, `tests/consent.test.ts` 3. Show consent next to the video at review The moment of approval is when it matters. Files: `src/web/review.ejs` ### Done when - [ ] Renders require a presenter id - [ ] A real person without consent cannot be selected - [ ] Review shows presenter kind and consent date - [ ] Revoking consent affects future renders only ### Watch out - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ## Not in this build - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. ## After v1, if you want it - A per-channel caption variant, since the same script reads differently in a feed that shows three lines and one that shows fifteen - A cost-per-finished-minute report from real renders, which is the only number that tells you whether this is cheaper than the subscription - A retention rule for media, because finished mp4s accumulate faster than anything else in this build ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The text model that writes scripts. SCRIPT_API_KEY=sk-...redacted # Required · secret. The video model. Set a provider-side spend limit as well. VIDEO_API_KEY=key-...redacted # Required. Stored per render, because output quality and pricing both move. VIDEO_MODEL_ID=a dated video model identifier # Required. Your own instance. This app talks to nothing else on the network except the two model providers. POSTIZ_URL=https://postiz.your-host.example # Required · secret. Created inside your Postiz instance. POSTIZ_API_KEY=pz-...redacted # Required. Burnt into every render. Cannot be empty. DISCLOSURE_LABEL=AI-generated video # Required. Downloaded and encoded files. These are the expensive artefacts, so back them up. MEDIA_DIR=./media # Required. Submissions stop here. Review and publishing keep working. MONTHLY_COST_CEILING=60.00
You are building a lean indie version of SocialFaktory. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # SocialFaktory · indie build A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, Express and SQLite, one local page with a queue and a review screen | the queue is the app; a render takes minutes and nobody watches a spinner | | Script | One model call per brief returning a hook, a spoken script and a visual prompt | the prompt lives in a file so you can tune voice without touching code | | Render | A hosted video model through its asynchronous queue API, polled, never re-submitted blind | video generation is the expensive call and the one you must not repeat by accident | | Labelling | ffmpeg burns a visible label and a verification step re-checks the marking after every encode | a disclosure you did not verify after encoding is a disclosure you probably lost | | Publishing | Your own self-hosted Postiz instance over its API, never a platform API directly | the platform credentials, the review queues and the labelling fields already live there | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer and ffmpeg** · free - Why: Runs the pipeline and burns the visible label into the video. - Get it: nodejs.org for Node, your package manager for ffmpeg. - Verify: node --version and ffmpeg -version both print - [ ] **An API key for a text model** · pennies per script - Why: Writes the hook, script and visual prompt from the brief. - Get it: Create a key with the provider you prefer and set a spend limit in their console. - Verify: One brief returns three validated fields - [ ] **An API key for a hosted video model with an asynchronous queue** · billed per second of output; measure one render before planning a schedule - Why: This is the expensive call and the one that must never be made twice for the same script. - Get it: Create a key with a provider whose queue API returns a request identifier you can poll. - Verify: One submit returns a request id and one poll returns a finished file - [ ] **A self-hosted Postiz instance with your channels connected** · free software; your own hosting - Why: It holds the platform credentials and the labelling fields, and it is the reason this build does not need a single platform developer account. - Get it: Run Postiz yourself and connect the channels you post to, then create an API key in it. - Verify: A test post from this app appears in the Postiz queue - [ ] **Who appears in these videos, and your written evidence for each** · free - Why: Article 50 covers disclosure; a person's voice and face are a separate matter and the one that produces angry messages. - Get it: List the stock presenters you will use, and for any real person obtain and file written permission with a date. - Verify: Every presenters row of kind real_person has a consent reference and date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. - pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks) - prompt pipelines tuned per model, format, and tone - an automated QA pass before anything ships - multi-brand workspaces and a scheduling calendar - a flat bill · DIY pays every model invoice directly, and video models are not cheap If one of those is essential to you, that is the reason to keep paying for SocialFaktory, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SocialFaktory The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a brief-to-video pipeline that renders a short, labels it as synthetic, and hands it to a publisher you already run to replace SocialFaktory. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This build makes a video of a person who does not exist, gives them a voice, and puts it on a public feed. Two things follow, and neither is a setting. Disclosure is mandatory in the EU and has been since 2 August 2026, when Article 50 of the AI Act started to apply. A deployer who uses an AI system to generate or manipulate image, audio or video content constituting a deep fake must disclose that the content has been artificially generated or manipulated. Where the work is evidently artistic, creative, satirical or fictional, the obligation is narrowed to disclosing that such content exists, in a way that does not spoil the work. Providers of the generators have a parallel duty to mark outputs in a machine-readable format. Penalties for breaching Article 50 run to fifteen million euro or three percent of worldwide annual turnover. So: every render carries a visible on-screen label and whatever machine-readable marking the generator attached, and a render missing either one cannot reach the publish step at all. Check what the platforms you post to require as well, because their synthetic-media labelling fields are separate from this and are set at publish time. Second, a face and a voice belong to somebody. Do not clone a real person's voice or likeness without written permission you can produce later. Use the generator's own stock presenters, or people who have signed something, and record which in the database before anything renders. ### Stack (fixed, do not substitute) - Runtime: Node 22, Express and SQLite, one local page with a queue and a review screen · the queue is the app; a render takes minutes and nobody watches a spinner - Script: One model call per brief returning a hook, a spoken script and a visual prompt · the prompt lives in a file so you can tune voice without touching code - Render: A hosted video model through its asynchronous queue API, polled, never re-submitted blind · video generation is the expensive call and the one you must not repeat by accident - Labelling: ffmpeg burns a visible label and a verification step re-checks the marking after every encode · a disclosure you did not verify after encoding is a disclosure you probably lost - Publishing: Your own self-hosted Postiz instance over its API, never a platform API directly · the platform credentials, the review queues and the labelling fields already live there ### Data model (create this before Phase 1) - briefs(id, topic, tone, target_seconds, created_at, brand_id) - scripts(id, brief_id, hook, spoken, visual_prompt, model_id, prompt_sha, cost_minor, created_at) - renders(id, script_id, provider, model_id, provider_request_id, state, mp4_path, seconds, cost_minor, submitted_at, finished_at) · provider_request_id is written before the first poll, not after - presenters(id, kind, source, consent_ref, consent_granted_at, notes) · kind is stock or real_person, and a real person without a consent_ref cannot be selected - disclosures(render_id, visible_label, burnt_in_at, marking_present_at_download, marking_present_after_encode, checked_at) - publications(id, render_id, postiz_post_id, channel, scheduled_for, ai_flag_set, published_at, state) - No row reaches publications without a disclosures row whose two marking checks are recorded, and no render starts without a presenter row that carries its consent evidence. ### Phase 1 · A brief becomes a script you can read Build: A form taking topic, tone and target duration. One call using a system prompt kept in prompts/script.md, returning three named fields validated against a schema. Store the prompt hash and the cost. Every field stays editable by hand before anything renders. Trap: Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. Done when: A brief produces three stored fields and a recorded cost, tested with a stubbed provider. The script is editable and the edit is what renders, verified by changing it and checking the render request. The system prompt lives in a file and its hash is stored with each script. Nothing renders automatically from the script screen, asserted by review. Do not build yet: No video, no labelling, no publishing. ### Phase 2 · A render that is never paid for twice Build: Write the render row and mark it submitting, call the provider's queue endpoint, store the returned request id immediately, then poll on a backoff. On startup, any row holding a request id is polled rather than resubmitted. Download the file once and record the actual cost and duration. Trap: A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. Done when: A render row carries its provider request id before the first poll, tested by killing the process between submit and poll. Restarting resumes polling for every row with a request id and creates no new submission, tested. Each script has a submission counter and a configured cap that refuses a further submit. The recorded cost comes from the provider's own reported figure or from its published per-second rate applied to the returned duration, and is never zero. Do not build yet: No labelling, no consent register, no publishing. ### Phase 3 · The label, and keeping it through the encode Build: Burn a short visible label into the video with ffmpeg at a size that survives a phone screen. Before that encode, record whether the downloaded file carried the provider's machine-readable marking. After the encode, check again and store both answers. If the marking is gone, either re-attach it or mark the render as visible-label-only and say so in the review screen. Trap: Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. Done when: Every render row has both marking checks recorded with timestamps, and neither is allowed to be null before publication. The visible label is present in the encoded file, verified by extracting a frame and inspecting it. A render whose marking was lost during encoding is flagged in the review screen with the fact stated plainly. The label text is configurable but cannot be set to an empty string. Do not build yet: No consent register and no publishing yet. ### Phase 4 · Who is in this video, and did they agree Build: A presenters table listing the generator's stock presenters and any real person whose voice or likeness you have permission to use, each real-person row carrying a reference to the signed permission and its date. Selecting a presenter is required before render. The review screen shows the presenter and its consent evidence next to the video. Trap: Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. Done when: A render cannot be created without a presenter id, enforced by the schema. A presenter of kind real_person without a consent reference cannot be selected, tested by trying. The review screen displays the presenter kind and consent date beside the player. Deleting a consent record marks affected presenters unusable for future renders without altering the history of past ones. Do not build yet: No publishing and no spend ceiling yet. ### Phase 5 · Handing it to the publisher Build: On approval, post the file and caption to your self-hosted Postiz with a scheduled time and the synthetic-content flag its channel supports. Never call a platform API directly from this code. Track the month's model and render spend and stop submitting when the ceiling is reached. Export every brief, script, render, disclosure and publication as JSONL with the media paths. Trap: Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. Done when: Nothing publishes without an explicit approval click, asserted in the suite. The publish request carries the synthetic-content flag where the channel supports one, and refuses to publish where the channel requires one and it could not be set. No platform hostname appears anywhere in the codebase outside documentation, checked by a grep test. The monthly ceiling stops new submissions and leaves review and publishing working. Export round-trips into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Calling TikTok, Instagram or YouTube APIs from this code, because the publisher you already run holds those credentials and the review process behind them is weeks of somebody else's life. - Cloning a real voice or face from recordings you happen to have, which is the feature this build deliberately does not have and the one most likely to end badly. - Multi-brand workspaces, teams and approval chains, which are the collaboration product rather than the generation pipeline. - An automated quality pass that decides a video is good enough to post, because you are the review step and that is the point. ### README must contain - That EU transparency rules for synthetic media have applied since 2 August 2026 and what the visible label and machine-readable marking in this build are for, with the article cited and the date you checked it - That the platforms have their own synthetic-content labelling fields set at publish time, separate from the label burnt into the file - That a real person's voice or likeness needs written permission recorded in the presenters table before anything renders - What one finished video costs to generate, measured from real runs rather than estimated, and where the monthly ceiling lives - That publishing happens through your own Postiz instance and this code never talks to a platform directly ===== AGENTS.md ===== # Agent instructions · SocialFaktory indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Express and SQLite, one local page with a queue and a review screen, One model call per brief returning a hook, a spoken script and a visual prompt, A hosted video model through its asynchronous queue API, polled, never re-submitted blind, ffmpeg burns a visible label and a verification step re-checks the marking after every encode, Your own self-hosted Postiz instance over its API, never a platform API directly. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ===== BUILD_PLAN.md ===== # Build plan · SocialFaktory A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A brief becomes a script you can read One model call turns a topic and tone into a hook, a spoken script and a visual prompt, all stored and editable. ### Steps 1. Keep the system prompt in a file and hash it You will tune this weekly and need to know which version wrote what. Files: `prompts/script.md`, `src/script/generate.ts` 2. Validate the three fields before storing A missing hook is better as an error than as an empty caption. Files: `src/script/schema.ts`, `tests/script.test.ts` 3. Put a human between the script and the render The render is the expensive step and the script is free to fix. Files: `src/web/script.ejs` ### Done when - [ ] A brief yields hook, script and visual prompt with a cost - [ ] The stored script is editable and the edit is what renders - [ ] The prompt file hash is recorded per script - [ ] No automatic path from script to render ### Watch out - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. ## Phase 2 · A render that is never paid for twice Submitting to the video model records the request identifier before polling, and a restart resumes rather than resubmits. ### Steps 1. Store the request id before you poll That identifier is the only proof you already paid. Files: `src/render/submit.ts`, `tests/resume.test.ts` 2. Resume on startup, never resubmit A crash is not a reason to buy the clip again. Files: `src/render/resume.ts` 3. Cap submissions per script The runaway retry loop is the failure mode with a price tag. Files: `src/render/cap.ts` ### Done when - [ ] The request id is written before the first poll - [ ] Restart resumes and never resubmits - [ ] A submission cap refuses further attempts - [ ] Cost and duration are recorded per render ### Watch out - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. ## Phase 3 · The label, and keeping it through the encode Every finished video has a visible synthetic-content label and its machine-readable marking is checked twice. ### Steps 1. Check the downloaded file for the marking first You need a before to have a loss. Files: `src/label/marking.ts` 2. Burn the visible label, then check the marking again Re-encoding is where provenance quietly dies. Files: `src/label/burn.ts`, `tests/label.test.ts` 3. Refuse to publish a render with a null check Unknown is not the same as present. Files: `src/publish/gate.ts` ### Done when - [ ] Both marking checks are recorded and non-null - [ ] The visible label appears in an extracted frame - [ ] A lost marking is surfaced in the review screen - [ ] The label text cannot be empty ### Watch out - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. ## Phase 4 · Who is in this video, and did they agree A presenter is chosen from a register, and a real person cannot be used without recorded consent. ### Steps 1. Make the presenter a row, not a phrase in a prompt A foreign key can be checked; a sentence cannot. Files: `src/db/migrations/003_presenters.sql` 2. Block rather than warn on missing consent A warning is a thing people click through at eleven at night. Files: `src/render/presenter.ts`, `tests/consent.test.ts` 3. Show consent next to the video at review The moment of approval is when it matters. Files: `src/web/review.ejs` ### Done when - [ ] Renders require a presenter id - [ ] A real person without consent cannot be selected - [ ] Review shows presenter kind and consent date - [ ] Revoking consent affects future renders only ### Watch out - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ## Not in this build - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. ## After v1, if you want it - A per-channel caption variant, since the same script reads differently in a feed that shows three lines and one that shows fifteen - A cost-per-finished-minute report from real renders, which is the only number that tells you whether this is cheaper than the subscription - A retention rule for media, because finished mp4s accumulate faster than anything else in this build ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The text model that writes scripts. SCRIPT_API_KEY=sk-...redacted # Required · secret. The video model. Set a provider-side spend limit as well. VIDEO_API_KEY=key-...redacted # Required. Stored per render, because output quality and pricing both move. VIDEO_MODEL_ID=a dated video model identifier # Required. Your own instance. This app talks to nothing else on the network except the two model providers. POSTIZ_URL=https://postiz.your-host.example # Required · secret. Created inside your Postiz instance. POSTIZ_API_KEY=pz-...redacted # Required. Burnt into every render. Cannot be empty. DISCLOSURE_LABEL=AI-generated video # Required. Downloaded and encoded files. These are the expensive artefacts, so back them up. MEDIA_DIR=./media # Required. Submissions stop here. Review and publishing keep working. MONTHLY_COST_CEILING=60.00
You are building a production product version of SocialFaktory. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # SocialFaktory · product brief ## Problem The generation half is honest API glue: script writing is an LLM call and the video itself comes from hosted model APIs (fal.ai and friends), so a personal brief-to-video pipeline is very buildable · but you pay those per-generation model invoices yourself either way. The publishing half is the same wall every scheduler hits: TikTok, Instagram, and YouTube OAuth apps are review-gated for weeks, which is why SocialFaktory itself publishes through Postiz. The honest DIY is a generation script feeding a self-hosted Postiz, and what's left over is the tuned prompt pipelines, the QA pass, and someone else eating the platform-review pain. ## Product outcome A pipeline where a brief becomes a labelled video you have watched, where a crash never buys the same clip twice, where the face in the video belongs to somebody who agreed, and where the platform credentials stay in the tool that already manages them. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Anthropic or OpenAI API key for scripts - video-generation API key (fal.ai or similar) - self-hosted Postiz for the platform OAuth and publishing - always-on box ## Explicit non-goals for v1 - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. - pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks) - prompt pipelines tuned per model, format, and tone - an automated QA pass before anything ships - multi-brand workspaces and a scheduling calendar - a flat bill · DIY pays every model invoice directly, and video models are not cheap ## Success criteria - No render is confirmed to publish with a null marking check - The visible label is confirmed present in an extracted frame - A real person without consent is confirmed unselectable - Restart after a crash is confirmed not to resubmit a render - No platform hostname is confirmed present in the codebase ===== BRIEF.md ===== # Build brief · SocialFaktory The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a brief-to-video pipeline that renders a short, labels it as synthetic, and hands it to a publisher you already run to replace SocialFaktory. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This build makes a video of a person who does not exist, gives them a voice, and puts it on a public feed. Two things follow, and neither is a setting. Disclosure is mandatory in the EU and has been since 2 August 2026, when Article 50 of the AI Act started to apply. A deployer who uses an AI system to generate or manipulate image, audio or video content constituting a deep fake must disclose that the content has been artificially generated or manipulated. Where the work is evidently artistic, creative, satirical or fictional, the obligation is narrowed to disclosing that such content exists, in a way that does not spoil the work. Providers of the generators have a parallel duty to mark outputs in a machine-readable format. Penalties for breaching Article 50 run to fifteen million euro or three percent of worldwide annual turnover. So: every render carries a visible on-screen label and whatever machine-readable marking the generator attached, and a render missing either one cannot reach the publish step at all. Check what the platforms you post to require as well, because their synthetic-media labelling fields are separate from this and are set at publish time. Second, a face and a voice belong to somebody. Do not clone a real person's voice or likeness without written permission you can produce later. Use the generator's own stock presenters, or people who have signed something, and record which in the database before anything renders. ### Stack (fixed, do not substitute) - Runtime: Node 22, Express and SQLite, one local page with a queue and a review screen · the queue is the app; a render takes minutes and nobody watches a spinner - Script: One model call per brief returning a hook, a spoken script and a visual prompt · the prompt lives in a file so you can tune voice without touching code - Render: A hosted video model through its asynchronous queue API, polled, never re-submitted blind · video generation is the expensive call and the one you must not repeat by accident - Labelling: ffmpeg burns a visible label and a verification step re-checks the marking after every encode · a disclosure you did not verify after encoding is a disclosure you probably lost - Publishing: Your own self-hosted Postiz instance over its API, never a platform API directly · the platform credentials, the review queues and the labelling fields already live there ### Data model (create this before Phase 1) - briefs(id, topic, tone, target_seconds, created_at, brand_id) - scripts(id, brief_id, hook, spoken, visual_prompt, model_id, prompt_sha, cost_minor, created_at) - renders(id, script_id, provider, model_id, provider_request_id, state, mp4_path, seconds, cost_minor, submitted_at, finished_at) · provider_request_id is written before the first poll, not after - presenters(id, kind, source, consent_ref, consent_granted_at, notes) · kind is stock or real_person, and a real person without a consent_ref cannot be selected - disclosures(render_id, visible_label, burnt_in_at, marking_present_at_download, marking_present_after_encode, checked_at) - publications(id, render_id, postiz_post_id, channel, scheduled_for, ai_flag_set, published_at, state) - No row reaches publications without a disclosures row whose two marking checks are recorded, and no render starts without a presenter row that carries its consent evidence. ### Phase 1 · A brief becomes a script you can read Build: A form taking topic, tone and target duration. One call using a system prompt kept in prompts/script.md, returning three named fields validated against a schema. Store the prompt hash and the cost. Every field stays editable by hand before anything renders. Trap: Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. Done when: A brief produces three stored fields and a recorded cost, tested with a stubbed provider. The script is editable and the edit is what renders, verified by changing it and checking the render request. The system prompt lives in a file and its hash is stored with each script. Nothing renders automatically from the script screen, asserted by review. Do not build yet: No video, no labelling, no publishing. ### Phase 2 · A render that is never paid for twice Build: Write the render row and mark it submitting, call the provider's queue endpoint, store the returned request id immediately, then poll on a backoff. On startup, any row holding a request id is polled rather than resubmitted. Download the file once and record the actual cost and duration. Trap: A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. Done when: A render row carries its provider request id before the first poll, tested by killing the process between submit and poll. Restarting resumes polling for every row with a request id and creates no new submission, tested. Each script has a submission counter and a configured cap that refuses a further submit. The recorded cost comes from the provider's own reported figure or from its published per-second rate applied to the returned duration, and is never zero. Do not build yet: No labelling, no consent register, no publishing. ### Phase 3 · The label, and keeping it through the encode Build: Burn a short visible label into the video with ffmpeg at a size that survives a phone screen. Before that encode, record whether the downloaded file carried the provider's machine-readable marking. After the encode, check again and store both answers. If the marking is gone, either re-attach it or mark the render as visible-label-only and say so in the review screen. Trap: Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. Done when: Every render row has both marking checks recorded with timestamps, and neither is allowed to be null before publication. The visible label is present in the encoded file, verified by extracting a frame and inspecting it. A render whose marking was lost during encoding is flagged in the review screen with the fact stated plainly. The label text is configurable but cannot be set to an empty string. Do not build yet: No consent register and no publishing yet. ### Phase 4 · Who is in this video, and did they agree Build: A presenters table listing the generator's stock presenters and any real person whose voice or likeness you have permission to use, each real-person row carrying a reference to the signed permission and its date. Selecting a presenter is required before render. The review screen shows the presenter and its consent evidence next to the video. Trap: Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. Done when: A render cannot be created without a presenter id, enforced by the schema. A presenter of kind real_person without a consent reference cannot be selected, tested by trying. The review screen displays the presenter kind and consent date beside the player. Deleting a consent record marks affected presenters unusable for future renders without altering the history of past ones. Do not build yet: No publishing and no spend ceiling yet. ### Phase 5 · Handing it to the publisher Build: On approval, post the file and caption to your self-hosted Postiz with a scheduled time and the synthetic-content flag its channel supports. Never call a platform API directly from this code. Track the month's model and render spend and stop submitting when the ceiling is reached. Export every brief, script, render, disclosure and publication as JSONL with the media paths. Trap: Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. Done when: Nothing publishes without an explicit approval click, asserted in the suite. The publish request carries the synthetic-content flag where the channel supports one, and refuses to publish where the channel requires one and it could not be set. No platform hostname appears anywhere in the codebase outside documentation, checked by a grep test. The monthly ceiling stops new submissions and leaves review and publishing working. Export round-trips into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Calling TikTok, Instagram or YouTube APIs from this code, because the publisher you already run holds those credentials and the review process behind them is weeks of somebody else's life. - Cloning a real voice or face from recordings you happen to have, which is the feature this build deliberately does not have and the one most likely to end badly. - Multi-brand workspaces, teams and approval chains, which are the collaboration product rather than the generation pipeline. - An automated quality pass that decides a video is good enough to post, because you are the review step and that is the point. ### README must contain - That EU transparency rules for synthetic media have applied since 2 August 2026 and what the visible label and machine-readable marking in this build are for, with the article cited and the date you checked it - That the platforms have their own synthetic-content labelling fields set at publish time, separate from the label burnt into the file - That a real person's voice or likeness needs written permission recorded in the presenters table before anything renders - What one finished video costs to generate, measured from real runs rather than estimated, and where the monthly ceiling lives - That publishing happens through your own Postiz instance and this code never talks to a platform directly ===== ARCHITECTURE.md ===== # Architecture · SocialFaktory ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, Express and SQLite, one local page with a queue and a review screen | the queue is the app; a render takes minutes and nobody watches a spinner | | Script | One model call per brief returning a hook, a spoken script and a visual prompt | the prompt lives in a file so you can tune voice without touching code | | Render | A hosted video model through its asynchronous queue API, polled, never re-submitted blind | video generation is the expensive call and the one you must not repeat by accident | | Labelling | ffmpeg burns a visible label and a verification step re-checks the marking after every encode | a disclosure you did not verify after encoding is a disclosure you probably lost | | Publishing | Your own self-hosted Postiz instance over its API, never a platform API directly | the platform credentials, the review queues and the labelling fields already live there | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Script | Brief to hook, script and visual prompt. | Any text provider, provided the three fields are validated. | | Render | Submission, request ids, polling and cost. | Any queue-based video provider, provided the request id is stored before polling. | | Disclosure | The visible label and both marking checks. | Not swappable for a checkbox in settings. | | Presenters | Stock and real-person rows with consent evidence. | Any store, provided consent stays mandatory for real people. | | Publishing | The call to your Postiz instance and the synthetic-content flag. | Any publisher that owns the platform credentials. Never direct platform calls. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `SCRIPT_API_KEY` · required, secret · The text model that writes scripts. - `VIDEO_API_KEY` · required, secret · The video model. Set a provider-side spend limit as well. - `VIDEO_MODEL_ID` · required · Stored per render, because output quality and pricing both move. - `POSTIZ_URL` · required · Your own instance. This app talks to nothing else on the network except the two model providers. - `POSTIZ_API_KEY` · required, secret · Created inside your Postiz instance. - `DISCLOSURE_LABEL` · required · Burnt into every render. Cannot be empty. - `MEDIA_DIR` · required · Downloaded and encoded files. These are the expensive artefacts, so back them up. - `MONTHLY_COST_CEILING` · required · Submissions stop here. Review and publishing keep working. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · SocialFaktory product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, Express and SQLite, one local page with a queue and a review screen, One model call per brief returning a hook, a spoken script and a visual prompt, A hosted video model through its asynchronous queue API, polled, never re-submitted blind, ffmpeg burns a visible label and a verification step re-checks the marking after every encode, Your own self-hosted Postiz instance over its API, never a platform API directly. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. - Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. ===== MILESTONES.md ===== # Delivery milestones · SocialFaktory Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A brief becomes a script you can read One model call turns a topic and tone into a hook, a spoken script and a visual prompt, all stored and editable. ### Steps 1. Keep the system prompt in a file and hash it You will tune this weekly and need to know which version wrote what. Files: `prompts/script.md`, `src/script/generate.ts` 2. Validate the three fields before storing A missing hook is better as an error than as an empty caption. Files: `src/script/schema.ts`, `tests/script.test.ts` 3. Put a human between the script and the render The render is the expensive step and the script is free to fix. Files: `src/web/script.ejs` ### Done when - [ ] A brief yields hook, script and visual prompt with a cost - [ ] The stored script is editable and the edit is what renders - [ ] The prompt file hash is recorded per script - [ ] No automatic path from script to render ### Watch out - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. ## M2 · A render that is never paid for twice Submitting to the video model records the request identifier before polling, and a restart resumes rather than resubmits. ### Steps 1. Store the request id before you poll That identifier is the only proof you already paid. Files: `src/render/submit.ts`, `tests/resume.test.ts` 2. Resume on startup, never resubmit A crash is not a reason to buy the clip again. Files: `src/render/resume.ts` 3. Cap submissions per script The runaway retry loop is the failure mode with a price tag. Files: `src/render/cap.ts` ### Done when - [ ] The request id is written before the first poll - [ ] Restart resumes and never resubmits - [ ] A submission cap refuses further attempts - [ ] Cost and duration are recorded per render ### Watch out - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. ## M3 · The label, and keeping it through the encode Every finished video has a visible synthetic-content label and its machine-readable marking is checked twice. ### Steps 1. Check the downloaded file for the marking first You need a before to have a loss. Files: `src/label/marking.ts` 2. Burn the visible label, then check the marking again Re-encoding is where provenance quietly dies. Files: `src/label/burn.ts`, `tests/label.test.ts` 3. Refuse to publish a render with a null check Unknown is not the same as present. Files: `src/publish/gate.ts` ### Done when - [ ] Both marking checks are recorded and non-null - [ ] The visible label appears in an extracted frame - [ ] A lost marking is surfaced in the review screen - [ ] The label text cannot be empty ### Watch out - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. ## M4 · Who is in this video, and did they agree A presenter is chosen from a register, and a real person cannot be used without recorded consent. ### Steps 1. Make the presenter a row, not a phrase in a prompt A foreign key can be checked; a sentence cannot. Files: `src/db/migrations/003_presenters.sql` 2. Block rather than warn on missing consent A warning is a thing people click through at eleven at night. Files: `src/render/presenter.ts`, `tests/consent.test.ts` 3. Show consent next to the video at review The moment of approval is when it matters. Files: `src/web/review.ejs` ### Done when - [ ] Renders require a presenter id - [ ] A real person without consent cannot be selected - [ ] Review shows presenter kind and consent date - [ ] Revoking consent affects future renders only ### Watch out - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ## M5 · Handing it to the publisher (production only) Approved videos go to your Postiz instance with the platform's synthetic-content flag set, and the month has a spending ceiling. ### Steps 1. Publish through the instance that owns the credentials Platform tokens are somebody's full-time job; do not make it yours. Files: `src/publish/postiz.ts` 2. Set the synthetic-content flag, and refuse if you cannot The disclosure rules do not have a best-effort setting. Files: `src/publish/flags.ts`, `tests/flags.test.ts` 3. Cap the month and export everything Video generation bills by the second and the surprise is monthly. Files: `src/cost/ceiling.ts`, `src/cli/export.ts` ### Done when - [ ] Approval is required before any publish - [ ] The synthetic-content flag is set or publication is refused - [ ] No platform hostnames appear in the code - [ ] The monthly ceiling stops submissions, not the app ### Watch out - Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. ===== OPERATIONS.md ===== # Operations · SocialFaktory ## Backup The database and the media directory, and the media directory is the one that cost money. A lost mp4 is a re-render at full price; a lost row is the record of what you published and what you disclosed, which is the thing you would actually need if somebody asked. ## Restore Restore the database first and confirm no render sits in submitting without a request id, since those are the rows that will resubmit. Then restore media and check that published rows still point at files that exist. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Month-to-date spend against the ceiling, the count of renders where the marking was present at download but gone after encode, and the count of publications where the synthetic-content flag could not be set. The second and third numbers are the ones that turn into a compliance problem. ## Incident checklist The incident in this build is a video that went out without its label, or with a face belonging to someone who did not agree. Both are recoverable only if you can find every publication and its disclosure row quickly, which is why publications keep the Postiz post id and the render keeps its checks. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No render is confirmed to publish with a null marking check - [ ] The visible label is confirmed present in an extracted frame - [ ] A real person without consent is confirmed unselectable - [ ] Restart after a crash is confirmed not to resubmit a render - [ ] No platform hostname is confirmed present in the codebase ## Launch constraint Do not market omitted SocialFaktory capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The text model that writes scripts. SCRIPT_API_KEY=sk-...redacted # Required · secret. The video model. Set a provider-side spend limit as well. VIDEO_API_KEY=key-...redacted # Required. Stored per render, because output quality and pricing both move. VIDEO_MODEL_ID=a dated video model identifier # Required. Your own instance. This app talks to nothing else on the network except the two model providers. POSTIZ_URL=https://postiz.your-host.example # Required · secret. Created inside your Postiz instance. POSTIZ_API_KEY=pz-...redacted # Required. Burnt into every render. Cannot be empty. DISCLOSURE_LABEL=AI-generated video # Required. Downloaded and encoded files. These are the expensive artefacts, so back them up. MEDIA_DIR=./media # Required. Submissions stop here. Review and publishing keep working. MONTHLY_COST_CEILING=60.00
# SocialFaktory · indie build A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, Express and SQLite, one local page with a queue and a review screen | the queue is the app; a render takes minutes and nobody watches a spinner | | Script | One model call per brief returning a hook, a spoken script and a visual prompt | the prompt lives in a file so you can tune voice without touching code | | Render | A hosted video model through its asynchronous queue API, polled, never re-submitted blind | video generation is the expensive call and the one you must not repeat by accident | | Labelling | ffmpeg burns a visible label and a verification step re-checks the marking after every encode | a disclosure you did not verify after encoding is a disclosure you probably lost | | Publishing | Your own self-hosted Postiz instance over its API, never a platform API directly | the platform credentials, the review queues and the labelling fields already live there | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer and ffmpeg** · free - Why: Runs the pipeline and burns the visible label into the video. - Get it: nodejs.org for Node, your package manager for ffmpeg. - Verify: node --version and ffmpeg -version both print - [ ] **An API key for a text model** · pennies per script - Why: Writes the hook, script and visual prompt from the brief. - Get it: Create a key with the provider you prefer and set a spend limit in their console. - Verify: One brief returns three validated fields - [ ] **An API key for a hosted video model with an asynchronous queue** · billed per second of output; measure one render before planning a schedule - Why: This is the expensive call and the one that must never be made twice for the same script. - Get it: Create a key with a provider whose queue API returns a request identifier you can poll. - Verify: One submit returns a request id and one poll returns a finished file - [ ] **A self-hosted Postiz instance with your channels connected** · free software; your own hosting - Why: It holds the platform credentials and the labelling fields, and it is the reason this build does not need a single platform developer account. - Get it: Run Postiz yourself and connect the channels you post to, then create an API key in it. - Verify: A test post from this app appears in the Postiz queue - [ ] **Who appears in these videos, and your written evidence for each** · free - Why: Article 50 covers disclosure; a person's voice and face are a separate matter and the one that produces angry messages. - Get it: List the stock presenters you will use, and for any real person obtain and file written permission with a date. - Verify: Every presenters row of kind real_person has a consent reference and date ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. - pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks) - prompt pipelines tuned per model, format, and tone - an automated QA pass before anything ships - multi-brand workspaces and a scheduling calendar - a flat bill · DIY pays every model invoice directly, and video models are not cheap If one of those is essential to you, that is the reason to keep paying for SocialFaktory, and the README should say so rather than pretend.
# Build brief · SocialFaktory The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a brief-to-video pipeline that renders a short, labels it as synthetic, and hands it to a publisher you already run to replace SocialFaktory. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This build makes a video of a person who does not exist, gives them a voice, and puts it on a public feed. Two things follow, and neither is a setting. Disclosure is mandatory in the EU and has been since 2 August 2026, when Article 50 of the AI Act started to apply. A deployer who uses an AI system to generate or manipulate image, audio or video content constituting a deep fake must disclose that the content has been artificially generated or manipulated. Where the work is evidently artistic, creative, satirical or fictional, the obligation is narrowed to disclosing that such content exists, in a way that does not spoil the work. Providers of the generators have a parallel duty to mark outputs in a machine-readable format. Penalties for breaching Article 50 run to fifteen million euro or three percent of worldwide annual turnover. So: every render carries a visible on-screen label and whatever machine-readable marking the generator attached, and a render missing either one cannot reach the publish step at all. Check what the platforms you post to require as well, because their synthetic-media labelling fields are separate from this and are set at publish time. Second, a face and a voice belong to somebody. Do not clone a real person's voice or likeness without written permission you can produce later. Use the generator's own stock presenters, or people who have signed something, and record which in the database before anything renders. ### Stack (fixed, do not substitute) - Runtime: Node 22, Express and SQLite, one local page with a queue and a review screen · the queue is the app; a render takes minutes and nobody watches a spinner - Script: One model call per brief returning a hook, a spoken script and a visual prompt · the prompt lives in a file so you can tune voice without touching code - Render: A hosted video model through its asynchronous queue API, polled, never re-submitted blind · video generation is the expensive call and the one you must not repeat by accident - Labelling: ffmpeg burns a visible label and a verification step re-checks the marking after every encode · a disclosure you did not verify after encoding is a disclosure you probably lost - Publishing: Your own self-hosted Postiz instance over its API, never a platform API directly · the platform credentials, the review queues and the labelling fields already live there ### Data model (create this before Phase 1) - briefs(id, topic, tone, target_seconds, created_at, brand_id) - scripts(id, brief_id, hook, spoken, visual_prompt, model_id, prompt_sha, cost_minor, created_at) - renders(id, script_id, provider, model_id, provider_request_id, state, mp4_path, seconds, cost_minor, submitted_at, finished_at) · provider_request_id is written before the first poll, not after - presenters(id, kind, source, consent_ref, consent_granted_at, notes) · kind is stock or real_person, and a real person without a consent_ref cannot be selected - disclosures(render_id, visible_label, burnt_in_at, marking_present_at_download, marking_present_after_encode, checked_at) - publications(id, render_id, postiz_post_id, channel, scheduled_for, ai_flag_set, published_at, state) - No row reaches publications without a disclosures row whose two marking checks are recorded, and no render starts without a presenter row that carries its consent evidence. ### Phase 1 · A brief becomes a script you can read Build: A form taking topic, tone and target duration. One call using a system prompt kept in prompts/script.md, returning three named fields validated against a schema. Store the prompt hash and the cost. Every field stays editable by hand before anything renders. Trap: Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. Done when: A brief produces three stored fields and a recorded cost, tested with a stubbed provider. The script is editable and the edit is what renders, verified by changing it and checking the render request. The system prompt lives in a file and its hash is stored with each script. Nothing renders automatically from the script screen, asserted by review. Do not build yet: No video, no labelling, no publishing. ### Phase 2 · A render that is never paid for twice Build: Write the render row and mark it submitting, call the provider's queue endpoint, store the returned request id immediately, then poll on a backoff. On startup, any row holding a request id is polled rather than resubmitted. Download the file once and record the actual cost and duration. Trap: A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. Done when: A render row carries its provider request id before the first poll, tested by killing the process between submit and poll. Restarting resumes polling for every row with a request id and creates no new submission, tested. Each script has a submission counter and a configured cap that refuses a further submit. The recorded cost comes from the provider's own reported figure or from its published per-second rate applied to the returned duration, and is never zero. Do not build yet: No labelling, no consent register, no publishing. ### Phase 3 · The label, and keeping it through the encode Build: Burn a short visible label into the video with ffmpeg at a size that survives a phone screen. Before that encode, record whether the downloaded file carried the provider's machine-readable marking. After the encode, check again and store both answers. If the marking is gone, either re-attach it or mark the render as visible-label-only and say so in the review screen. Trap: Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. Done when: Every render row has both marking checks recorded with timestamps, and neither is allowed to be null before publication. The visible label is present in the encoded file, verified by extracting a frame and inspecting it. A render whose marking was lost during encoding is flagged in the review screen with the fact stated plainly. The label text is configurable but cannot be set to an empty string. Do not build yet: No consent register and no publishing yet. ### Phase 4 · Who is in this video, and did they agree Build: A presenters table listing the generator's stock presenters and any real person whose voice or likeness you have permission to use, each real-person row carrying a reference to the signed permission and its date. Selecting a presenter is required before render. The review screen shows the presenter and its consent evidence next to the video. Trap: Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. Done when: A render cannot be created without a presenter id, enforced by the schema. A presenter of kind real_person without a consent reference cannot be selected, tested by trying. The review screen displays the presenter kind and consent date beside the player. Deleting a consent record marks affected presenters unusable for future renders without altering the history of past ones. Do not build yet: No publishing and no spend ceiling yet. ### Phase 5 · Handing it to the publisher Build: On approval, post the file and caption to your self-hosted Postiz with a scheduled time and the synthetic-content flag its channel supports. Never call a platform API directly from this code. Track the month's model and render spend and stop submitting when the ceiling is reached. Export every brief, script, render, disclosure and publication as JSONL with the media paths. Trap: Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. Done when: Nothing publishes without an explicit approval click, asserted in the suite. The publish request carries the synthetic-content flag where the channel supports one, and refuses to publish where the channel requires one and it could not be set. No platform hostname appears anywhere in the codebase outside documentation, checked by a grep test. The monthly ceiling stops new submissions and leaves review and publishing working. Export round-trips into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Calling TikTok, Instagram or YouTube APIs from this code, because the publisher you already run holds those credentials and the review process behind them is weeks of somebody else's life. - Cloning a real voice or face from recordings you happen to have, which is the feature this build deliberately does not have and the one most likely to end badly. - Multi-brand workspaces, teams and approval chains, which are the collaboration product rather than the generation pipeline. - An automated quality pass that decides a video is good enough to post, because you are the review step and that is the point. ### README must contain - That EU transparency rules for synthetic media have applied since 2 August 2026 and what the visible label and machine-readable marking in this build are for, with the article cited and the date you checked it - That the platforms have their own synthetic-content labelling fields set at publish time, separate from the label burnt into the file - That a real person's voice or likeness needs written permission recorded in the presenters table before anything renders - What one finished video costs to generate, measured from real runs rather than estimated, and where the monthly ceiling lives - That publishing happens through your own Postiz instance and this code never talks to a platform directly
# Agent instructions · SocialFaktory indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22, Express and SQLite, one local page with a queue and a review screen, One model call per brief returning a hook, a spoken script and a visual prompt, A hosted video model through its asynchronous queue API, polled, never re-submitted blind, ffmpeg burns a visible label and a verification step re-checks the marking after every encode, Your own self-hosted Postiz instance over its API, never a platform API directly. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it.
# Build plan · SocialFaktory A personal video pipeline whose hard problems are proving a render is labelled as synthetic and keeping that marking through every re-encode, never paying twice for a render that timed out rather than failed, and keeping the publishing where the platform credentials already live. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · A brief becomes a script you can read One model call turns a topic and tone into a hook, a spoken script and a visual prompt, all stored and editable. ### Steps 1. Keep the system prompt in a file and hash it You will tune this weekly and need to know which version wrote what. Files: `prompts/script.md`, `src/script/generate.ts` 2. Validate the three fields before storing A missing hook is better as an error than as an empty caption. Files: `src/script/schema.ts`, `tests/script.test.ts` 3. Put a human between the script and the render The render is the expensive step and the script is free to fix. Files: `src/web/script.ejs` ### Done when - [ ] A brief yields hook, script and visual prompt with a cost - [ ] The stored script is editable and the edit is what renders - [ ] The prompt file hash is recorded per script - [ ] No automatic path from script to render ### Watch out - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. ## Phase 2 · A render that is never paid for twice Submitting to the video model records the request identifier before polling, and a restart resumes rather than resubmits. ### Steps 1. Store the request id before you poll That identifier is the only proof you already paid. Files: `src/render/submit.ts`, `tests/resume.test.ts` 2. Resume on startup, never resubmit A crash is not a reason to buy the clip again. Files: `src/render/resume.ts` 3. Cap submissions per script The runaway retry loop is the failure mode with a price tag. Files: `src/render/cap.ts` ### Done when - [ ] The request id is written before the first poll - [ ] Restart resumes and never resubmits - [ ] A submission cap refuses further attempts - [ ] Cost and duration are recorded per render ### Watch out - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. ## Phase 3 · The label, and keeping it through the encode Every finished video has a visible synthetic-content label and its machine-readable marking is checked twice. ### Steps 1. Check the downloaded file for the marking first You need a before to have a loss. Files: `src/label/marking.ts` 2. Burn the visible label, then check the marking again Re-encoding is where provenance quietly dies. Files: `src/label/burn.ts`, `tests/label.test.ts` 3. Refuse to publish a render with a null check Unknown is not the same as present. Files: `src/publish/gate.ts` ### Done when - [ ] Both marking checks are recorded and non-null - [ ] The visible label appears in an extracted frame - [ ] A lost marking is surfaced in the review screen - [ ] The label text cannot be empty ### Watch out - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. ## Phase 4 · Who is in this video, and did they agree A presenter is chosen from a register, and a real person cannot be used without recorded consent. ### Steps 1. Make the presenter a row, not a phrase in a prompt A foreign key can be checked; a sentence cannot. Files: `src/db/migrations/003_presenters.sql` 2. Block rather than warn on missing consent A warning is a thing people click through at eleven at night. Files: `src/render/presenter.ts`, `tests/consent.test.ts` 3. Show consent next to the video at review The moment of approval is when it matters. Files: `src/web/review.ejs` ### Done when - [ ] Renders require a presenter id - [ ] A real person without consent cannot be selected - [ ] Review shows presenter kind and consent date - [ ] Revoking consent affects future renders only ### Watch out - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ## Not in this build - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. ## After v1, if you want it - A per-channel caption variant, since the same script reads differently in a feed that shows three lines and one that shows fifteen - A cost-per-finished-minute report from real renders, which is the only number that tells you whether this is cheaper than the subscription - A retention rule for media, because finished mp4s accumulate faster than anything else in this build
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The text model that writes scripts. SCRIPT_API_KEY=sk-...redacted # Required · secret. The video model. Set a provider-side spend limit as well. VIDEO_API_KEY=key-...redacted # Required. Stored per render, because output quality and pricing both move. VIDEO_MODEL_ID=a dated video model identifier # Required. Your own instance. This app talks to nothing else on the network except the two model providers. POSTIZ_URL=https://postiz.your-host.example # Required · secret. Created inside your Postiz instance. POSTIZ_API_KEY=pz-...redacted # Required. Burnt into every render. Cannot be empty. DISCLOSURE_LABEL=AI-generated video # Required. Downloaded and encoded files. These are the expensive artefacts, so back them up. MEDIA_DIR=./media # Required. Submissions stop here. Review and publishing keep working. MONTHLY_COST_CEILING=60.00
# SocialFaktory · product brief ## Problem The generation half is honest API glue: script writing is an LLM call and the video itself comes from hosted model APIs (fal.ai and friends), so a personal brief-to-video pipeline is very buildable · but you pay those per-generation model invoices yourself either way. The publishing half is the same wall every scheduler hits: TikTok, Instagram, and YouTube OAuth apps are review-gated for weeks, which is why SocialFaktory itself publishes through Postiz. The honest DIY is a generation script feeding a self-hosted Postiz, and what's left over is the tuned prompt pipelines, the QA pass, and someone else eating the platform-review pain. ## Product outcome A pipeline where a brief becomes a labelled video you have watched, where a crash never buys the same clip twice, where the face in the video belongs to somebody who agreed, and where the platform credentials stay in the tool that already manages them. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Anthropic or OpenAI API key for scripts - video-generation API key (fal.ai or similar) - self-hosted Postiz for the platform OAuth and publishing - always-on box ## Explicit non-goals for v1 - Direct platform posting or platform developer accounts. - Voice or likeness cloning from recordings. - Multi-brand workspaces and team approvals. - An automated quality gate that decides what is postable. - pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks) - prompt pipelines tuned per model, format, and tone - an automated QA pass before anything ships - multi-brand workspaces and a scheduling calendar - a flat bill · DIY pays every model invoice directly, and video models are not cheap ## Success criteria - No render is confirmed to publish with a null marking check - The visible label is confirmed present in an extracted frame - A real person without consent is confirmed unselectable - Restart after a crash is confirmed not to resubmit a render - No platform hostname is confirmed present in the codebase
# Build brief · SocialFaktory The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a brief-to-video pipeline that renders a short, labels it as synthetic, and hands it to a publisher you already run to replace SocialFaktory. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero This build makes a video of a person who does not exist, gives them a voice, and puts it on a public feed. Two things follow, and neither is a setting. Disclosure is mandatory in the EU and has been since 2 August 2026, when Article 50 of the AI Act started to apply. A deployer who uses an AI system to generate or manipulate image, audio or video content constituting a deep fake must disclose that the content has been artificially generated or manipulated. Where the work is evidently artistic, creative, satirical or fictional, the obligation is narrowed to disclosing that such content exists, in a way that does not spoil the work. Providers of the generators have a parallel duty to mark outputs in a machine-readable format. Penalties for breaching Article 50 run to fifteen million euro or three percent of worldwide annual turnover. So: every render carries a visible on-screen label and whatever machine-readable marking the generator attached, and a render missing either one cannot reach the publish step at all. Check what the platforms you post to require as well, because their synthetic-media labelling fields are separate from this and are set at publish time. Second, a face and a voice belong to somebody. Do not clone a real person's voice or likeness without written permission you can produce later. Use the generator's own stock presenters, or people who have signed something, and record which in the database before anything renders. ### Stack (fixed, do not substitute) - Runtime: Node 22, Express and SQLite, one local page with a queue and a review screen · the queue is the app; a render takes minutes and nobody watches a spinner - Script: One model call per brief returning a hook, a spoken script and a visual prompt · the prompt lives in a file so you can tune voice without touching code - Render: A hosted video model through its asynchronous queue API, polled, never re-submitted blind · video generation is the expensive call and the one you must not repeat by accident - Labelling: ffmpeg burns a visible label and a verification step re-checks the marking after every encode · a disclosure you did not verify after encoding is a disclosure you probably lost - Publishing: Your own self-hosted Postiz instance over its API, never a platform API directly · the platform credentials, the review queues and the labelling fields already live there ### Data model (create this before Phase 1) - briefs(id, topic, tone, target_seconds, created_at, brand_id) - scripts(id, brief_id, hook, spoken, visual_prompt, model_id, prompt_sha, cost_minor, created_at) - renders(id, script_id, provider, model_id, provider_request_id, state, mp4_path, seconds, cost_minor, submitted_at, finished_at) · provider_request_id is written before the first poll, not after - presenters(id, kind, source, consent_ref, consent_granted_at, notes) · kind is stock or real_person, and a real person without a consent_ref cannot be selected - disclosures(render_id, visible_label, burnt_in_at, marking_present_at_download, marking_present_after_encode, checked_at) - publications(id, render_id, postiz_post_id, channel, scheduled_for, ai_flag_set, published_at, state) - No row reaches publications without a disclosures row whose two marking checks are recorded, and no render starts without a presenter row that carries its consent evidence. ### Phase 1 · A brief becomes a script you can read Build: A form taking topic, tone and target duration. One call using a system prompt kept in prompts/script.md, returning three named fields validated against a schema. Store the prompt hash and the cost. Every field stays editable by hand before anything renders. Trap: Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. Done when: A brief produces three stored fields and a recorded cost, tested with a stubbed provider. The script is editable and the edit is what renders, verified by changing it and checking the render request. The system prompt lives in a file and its hash is stored with each script. Nothing renders automatically from the script screen, asserted by review. Do not build yet: No video, no labelling, no publishing. ### Phase 2 · A render that is never paid for twice Build: Write the render row and mark it submitting, call the provider's queue endpoint, store the returned request id immediately, then poll on a backoff. On startup, any row holding a request id is polled rather than resubmitted. Download the file once and record the actual cost and duration. Trap: A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. Done when: A render row carries its provider request id before the first poll, tested by killing the process between submit and poll. Restarting resumes polling for every row with a request id and creates no new submission, tested. Each script has a submission counter and a configured cap that refuses a further submit. The recorded cost comes from the provider's own reported figure or from its published per-second rate applied to the returned duration, and is never zero. Do not build yet: No labelling, no consent register, no publishing. ### Phase 3 · The label, and keeping it through the encode Build: Burn a short visible label into the video with ffmpeg at a size that survives a phone screen. Before that encode, record whether the downloaded file carried the provider's machine-readable marking. After the encode, check again and store both answers. If the marking is gone, either re-attach it or mark the render as visible-label-only and say so in the review screen. Trap: Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. Done when: Every render row has both marking checks recorded with timestamps, and neither is allowed to be null before publication. The visible label is present in the encoded file, verified by extracting a frame and inspecting it. A render whose marking was lost during encoding is flagged in the review screen with the fact stated plainly. The label text is configurable but cannot be set to an empty string. Do not build yet: No consent register and no publishing yet. ### Phase 4 · Who is in this video, and did they agree Build: A presenters table listing the generator's stock presenters and any real person whose voice or likeness you have permission to use, each real-person row carrying a reference to the signed permission and its date. Selecting a presenter is required before render. The review screen shows the presenter and its consent evidence next to the video. Trap: Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. Done when: A render cannot be created without a presenter id, enforced by the schema. A presenter of kind real_person without a consent reference cannot be selected, tested by trying. The review screen displays the presenter kind and consent date beside the player. Deleting a consent record marks affected presenters unusable for future renders without altering the history of past ones. Do not build yet: No publishing and no spend ceiling yet. ### Phase 5 · Handing it to the publisher Build: On approval, post the file and caption to your self-hosted Postiz with a scheduled time and the synthetic-content flag its channel supports. Never call a platform API directly from this code. Track the month's model and render spend and stop submitting when the ceiling is reached. Export every brief, script, render, disclosure and publication as JSONL with the media paths. Trap: Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption. Done when: Nothing publishes without an explicit approval click, asserted in the suite. The publish request carries the synthetic-content flag where the channel supports one, and refuses to publish where the channel requires one and it could not be set. No platform hostname appears anywhere in the codebase outside documentation, checked by a grep test. The monthly ceiling stops new submissions and leaves review and publishing working. Export round-trips into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Calling TikTok, Instagram or YouTube APIs from this code, because the publisher you already run holds those credentials and the review process behind them is weeks of somebody else's life. - Cloning a real voice or face from recordings you happen to have, which is the feature this build deliberately does not have and the one most likely to end badly. - Multi-brand workspaces, teams and approval chains, which are the collaboration product rather than the generation pipeline. - An automated quality pass that decides a video is good enough to post, because you are the review step and that is the point. ### README must contain - That EU transparency rules for synthetic media have applied since 2 August 2026 and what the visible label and machine-readable marking in this build are for, with the article cited and the date you checked it - That the platforms have their own synthetic-content labelling fields set at publish time, separate from the label burnt into the file - That a real person's voice or likeness needs written permission recorded in the presenters table before anything renders - What one finished video costs to generate, measured from real runs rather than estimated, and where the monthly ceiling lives - That publishing happens through your own Postiz instance and this code never talks to a platform directly
# Architecture · SocialFaktory ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22, Express and SQLite, one local page with a queue and a review screen | the queue is the app; a render takes minutes and nobody watches a spinner | | Script | One model call per brief returning a hook, a spoken script and a visual prompt | the prompt lives in a file so you can tune voice without touching code | | Render | A hosted video model through its asynchronous queue API, polled, never re-submitted blind | video generation is the expensive call and the one you must not repeat by accident | | Labelling | ffmpeg burns a visible label and a verification step re-checks the marking after every encode | a disclosure you did not verify after encoding is a disclosure you probably lost | | Publishing | Your own self-hosted Postiz instance over its API, never a platform API directly | the platform credentials, the review queues and the labelling fields already live there | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Script | Brief to hook, script and visual prompt. | Any text provider, provided the three fields are validated. | | Render | Submission, request ids, polling and cost. | Any queue-based video provider, provided the request id is stored before polling. | | Disclosure | The visible label and both marking checks. | Not swappable for a checkbox in settings. | | Presenters | Stock and real-person rows with consent evidence. | Any store, provided consent stays mandatory for real people. | | Publishing | The call to your Postiz instance and the synthetic-content flag. | Any publisher that owns the platform credentials. Never direct platform calls. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `SCRIPT_API_KEY` · required, secret · The text model that writes scripts. - `VIDEO_API_KEY` · required, secret · The video model. Set a provider-side spend limit as well. - `VIDEO_MODEL_ID` · required · Stored per render, because output quality and pricing both move. - `POSTIZ_URL` · required · Your own instance. This app talks to nothing else on the network except the two model providers. - `POSTIZ_API_KEY` · required, secret · Created inside your Postiz instance. - `DISCLOSURE_LABEL` · required · Burnt into every render. Cannot be empty. - `MEDIA_DIR` · required · Downloaded and encoded files. These are the expensive artefacts, so back them up. - `MONTHLY_COST_CEILING` · required · Submissions stop here. Review and publishing keep working. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · SocialFaktory product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22, Express and SQLite, one local page with a queue and a review screen, One model call per brief returning a hook, a spoken script and a visual prompt, A hosted video model through its asynchronous queue API, polled, never re-submitted blind, ffmpeg burns a visible label and a verification step re-checks the marking after every encode, Your own self-hosted Postiz instance over its API, never a platform API directly. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. - Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption.
# Delivery milestones · SocialFaktory Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · A brief becomes a script you can read One model call turns a topic and tone into a hook, a spoken script and a visual prompt, all stored and editable. ### Steps 1. Keep the system prompt in a file and hash it You will tune this weekly and need to know which version wrote what. Files: `prompts/script.md`, `src/script/generate.ts` 2. Validate the three fields before storing A missing hook is better as an error than as an empty caption. Files: `src/script/schema.ts`, `tests/script.test.ts` 3. Put a human between the script and the render The render is the expensive step and the script is free to fix. Files: `src/web/script.ejs` ### Done when - [ ] A brief yields hook, script and visual prompt with a cost - [ ] The stored script is editable and the edit is what renders - [ ] The prompt file hash is recorded per script - [ ] No automatic path from script to render ### Watch out - Letting the script step feed straight into the render step means you pay for video before a human has read a word, and a script that misreads the brief costs a full render to discover. Put the script on screen as text with an edit box, and make rendering a separate deliberate action. ## M2 · A render that is never paid for twice Submitting to the video model records the request identifier before polling, and a restart resumes rather than resubmits. ### Steps 1. Store the request id before you poll That identifier is the only proof you already paid. Files: `src/render/submit.ts`, `tests/resume.test.ts` 2. Resume on startup, never resubmit A crash is not a reason to buy the clip again. Files: `src/render/resume.ts` 3. Cap submissions per script The runaway retry loop is the failure mode with a price tag. Files: `src/render/cap.ts` ### Done when - [ ] The request id is written before the first poll - [ ] Restart resumes and never resubmits - [ ] A submission cap refuses further attempts - [ ] Cost and duration are recorded per render ### Watch out - A polling loop that treats a slow job as a failure and submits again turns one twenty-second clip into two invoices, and video models are priced per second of output rather than per call, so this is the mistake that actually hurts. Store the request id before the first poll, resume from it after any crash, and put a hard cap on how many times a script may be submitted at all. ## M3 · The label, and keeping it through the encode Every finished video has a visible synthetic-content label and its machine-readable marking is checked twice. ### Steps 1. Check the downloaded file for the marking first You need a before to have a loss. Files: `src/label/marking.ts` 2. Burn the visible label, then check the marking again Re-encoding is where provenance quietly dies. Files: `src/label/burn.ts`, `tests/label.test.ts` 3. Refuse to publish a render with a null check Unknown is not the same as present. Files: `src/publish/gate.ts` ### Done when - [ ] Both marking checks are recorded and non-null - [ ] The visible label appears in an extracted frame - [ ] A lost marking is surfaced in the review screen - [ ] The label text cannot be empty ### Watch out - Most transcoding drops metadata it does not recognise, so the provenance marking a generator attached at creation is routinely lost the moment you re-encode to burn in a caption, and nobody notices because the video still plays. Check for the marking on the downloaded file and again on the encoded one, store both results, and treat a silent loss as a finding rather than an accident. ## M4 · Who is in this video, and did they agree A presenter is chosen from a register, and a real person cannot be used without recorded consent. ### Steps 1. Make the presenter a row, not a phrase in a prompt A foreign key can be checked; a sentence cannot. Files: `src/db/migrations/003_presenters.sql` 2. Block rather than warn on missing consent A warning is a thing people click through at eleven at night. Files: `src/render/presenter.ts`, `tests/consent.test.ts` 3. Show consent next to the video at review The moment of approval is when it matters. Files: `src/web/review.ejs` ### Done when - [ ] Renders require a presenter id - [ ] A real person without consent cannot be selected - [ ] Review shows presenter kind and consent date - [ ] Revoking consent affects future renders only ### Watch out - Treating the presenter as a string in the visual prompt means nothing in the system knows whether the face belongs to a stock model or to a colleague whose voice you sampled from a meeting recording, and the difference is the entire legal exposure. Make the presenter a foreign key, make consent evidence mandatory on real-person rows, and block the render rather than warning about it. ## M5 · Handing it to the publisher (production only) Approved videos go to your Postiz instance with the platform's synthetic-content flag set, and the month has a spending ceiling. ### Steps 1. Publish through the instance that owns the credentials Platform tokens are somebody's full-time job; do not make it yours. Files: `src/publish/postiz.ts` 2. Set the synthetic-content flag, and refuse if you cannot The disclosure rules do not have a best-effort setting. Files: `src/publish/flags.ts`, `tests/flags.test.ts` 3. Cap the month and export everything Video generation bills by the second and the surprise is monthly. Files: `src/cost/ceiling.ts`, `src/cli/export.ts` ### Done when - [ ] Approval is required before any publish - [ ] The synthetic-content flag is set or publication is refused - [ ] No platform hostnames appear in the code - [ ] The monthly ceiling stops submissions, not the app ### Watch out - Wiring your own client identifiers into the platform APIs to skip the publisher means you now own the platform review process, the token refreshes and the rate limits, and the first thing that breaks is a posting schedule you stopped watching. Let the instance that already holds those credentials do the posting, and keep this code to producing a labelled file and a caption.
# Operations · SocialFaktory ## Backup The database and the media directory, and the media directory is the one that cost money. A lost mp4 is a re-render at full price; a lost row is the record of what you published and what you disclosed, which is the thing you would actually need if somebody asked. ## Restore Restore the database first and confirm no render sits in submitting without a request id, since those are the rows that will resubmit. Then restore media and check that published rows still point at files that exist. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Month-to-date spend against the ceiling, the count of renders where the marking was present at download but gone after encode, and the count of publications where the synthetic-content flag could not be set. The second and third numbers are the ones that turn into a compliance problem. ## Incident checklist The incident in this build is a video that went out without its label, or with a face belonging to someone who did not agree. Both are recoverable only if you can find every publication and its disclosure row quickly, which is why publications keep the Postiz post id and the render keeps its checks. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No render is confirmed to publish with a null marking check - [ ] The visible label is confirmed present in an extracted frame - [ ] A real person without consent is confirmed unselectable - [ ] Restart after a crash is confirmed not to resubmit a render - [ ] No platform hostname is confirmed present in the codebase ## Launch constraint Do not market omitted SocialFaktory capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The text model that writes scripts. SCRIPT_API_KEY=sk-...redacted # Required · secret. The video model. Set a provider-side spend limit as well. VIDEO_API_KEY=key-...redacted # Required. Stored per render, because output quality and pricing both move. VIDEO_MODEL_ID=a dated video model identifier # Required. Your own instance. This app talks to nothing else on the network except the two model providers. POSTIZ_URL=https://postiz.your-host.example # Required · secret. Created inside your Postiz instance. POSTIZ_API_KEY=pz-...redacted # Required. Burnt into every render. Cannot be empty. DISCLOSURE_LABEL=AI-generated video # Required. Downloaded and encoded files. These are the expensive artefacts, so back them up. MEDIA_DIR=./media # Required. Submissions stop here. Review and publishing keep working. MONTHLY_COST_CEILING=60.00
$ choose a build depth, inspect the files, then open the complete pack in your agent
Video model APIs churn monthly (pricing, quality, aspect-ratio quirks, content filters) and social platforms churn their publishing rules just as fast; the subscription outsources both treadmills. The flat price also converts unpredictable per-second video generation invoices into one known number, and the platform OAuth apps arrive pre-approved instead of after weeks of app review.
xpre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks)
xprompt pipelines tuned per model, format, and tone
xan automated QA pass before anything ships
xmulti-brand workspaces and a scheduling calendar
xa flat bill · DIY pays every model invoice directly, and video models are not cheap
SocialFaktory pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| pro | $49 | — | 490 credits/month, unlimited brand workspaces, 7 publishing channels, 8 dubbing languages |
free tierno free tier
billingmonthly only, no annual plan published; cancel anytime
hidden costsVideo output is credit-metered at 490 credits/month; the page does not publish per-render credit consumption or paid overage pricing.
verified 2026-08-14 · source ↗
Vibecode SocialFaktory
Kinda. The core of SocialFaktory is buildable in a weekend with the prompt on this page, but there are real gaps: pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks), prompt pipelines tuned per model, format, and tone. Read the honest list above before committing.
How much does SocialFaktory cost?
SocialFaktory costs about $49/month (Base plan, checked 2026-08-03), which is $588 per year.
What do I lose by replacing SocialFaktory?
Honestly: pre-approved platform OAuth apps (TikTok/Instagram/YouTube reviews take weeks); prompt pipelines tuned per model, format, and tone; an automated QA pass before anything ships; multi-brand workspaces and a scheduling calendar; a flat bill · DIY pays every model invoice directly, and video models are not cheap. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to SocialFaktory?
Yes: Postiz (Open-source social publishing · the scheduling/OAuth half of this product, self-hostable.), Mixpost (Self-hosted social media management, an alternative publishing backbone.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.