Skip to content

feat: publish finished stick figure videos to social platforms via Upload-Post - #339

Open
mutonby wants to merge 2 commits into
krillinai:masterfrom
mutonby:feat/upload-post-publish
Open

mutonby wants to merge 2 commits into
krillinai:masterfrom
mutonby:feat/upload-post-publish

Conversation

@mutonby

@mutonby mutonby commented Sep 29, 2026

Copy link
Copy Markdown

Summary

The YouTube Shorts / stick figure pipeline (#327) ends with a publishable package (short.mp4, thumbnail, publish-copy.md, delivery manifest), but it deliberately stops short of publishing it. This PR adds that last step as an optional, user-confirmed stage: a finished delivery can be posted to TikTok, Instagram, YouTube, LinkedIn, Facebook, X, Threads, and Bluesky through Upload-Post, with per-platform results shown on the Video delivery step.

Disclosure: I'm on the Upload-Post team. Upload-Post is a hosted service. Its free plan allows 10 uploads a month on every platform here except TikTok, which needs a paid plan. Nothing is sent unless the user adds an Upload-Post API key under AI Services → Publishing and confirms a publish.

cc @wulien (code)

What's included

Settings. A new AI Services → Publishing category with the Upload-Post API key and profile name. The key goes through the existing credential split (credentials.json), redaction and retain paths. Older saved configs get empty defaults.

Daemon / protocol.

  • social-publish stage on stickman-video (executor upload-post-publish, optional, resultVersionPolicy: 'none', invalidateDependentArtifacts: false). The workflow never queues it.
  • confirm-social-publish action. It is user-only: the Agent gets creator_user_confirmation_required. It stores the platforms, copy, privacy, AI label and the exact delivery manifest in state.socialPublish. update-settings / undo-action patches can't write socialPublish. The agent guidance says the Agent must not publish.
  • At most one publish per confirmation. The confirmation id is sent as Upload-Post request_id and Idempotency-Key, and the provider request ledger (registerBeforeSubmit / markWaitingRemote / …) refuses a second submission for the same confirmation. Only a request that Upload-Post rejected (4xx, nothing created) can be retried with the same confirmation.
  • No duplicate on a dropped connection. The file is never re-sent: the stage looks the request up by its id. If Upload-Post has no record of it, the ledger goes to unknown_remote_acceptance and the existing resolution flow takes over. createUploadPostProviderCapabilities lets recover() query an interrupted publish by request id.
  • Async upload + polling. The video is streamed as multipart through the configured AI Services proxy when set. The stage polls /api/uploadposts/status for up to 10 min (the result becomes submitted after that) and tolerates transient poll errors. Results are normalized: private uploads with no public URL, skipped (not connected) platforms, TikTok inbox/drafts delivery, and per-platform errors.
  • A social_publish_result artifact (social-publish.json) with the per-platform results in its metadata.
  • Preflight blocks the stage until publishing is configured, with a repair deep link to #/settings?tab=ai-services&section=publishing. A missing config maps to needs_input with the same deep link.

Web.

  • A Publish to social platforms panel on the Video delivery step:
    • Platform checkboxes: 9:16 defaults to TikTok, Instagram and YouTube; 16:9 defaults to YouTube and LinkedIn.
    • Title and description prefilled from the approved script, and editable.
    • YouTube Private by default. TikTok uses the account's own default privacy.
    • "Label as AI-generated content" on by default.
    • Confirm dialog before anything is sent, then per-platform results with links.
  • When publishing isn't configured, the panel shows a link to the settings page instead of a button that does nothing.
  • section=publishing route, and a panel adapter label for the new stage.

Docs. docs/social-publishing.md (setup, behavior, how it works), linked from the README.

Not in this PR (possible follow-ups)

  • Publishing from other templates (video translation, auto-clip, video generation). The stage and client are template-agnostic, but each template's delivery UI differs, so I kept this to the Shorts pipeline.
  • Scheduling a publish time, and a "refresh results" action for publishes still submitted after 10 minutes.
  • Listing the profile's connected platforms in the panel. For now, unconnected platforms come back as skipped.

Change risk level

  • P0 — Low risk
  • P1 — Medium risk
  • P2 — High risk: protocol (CreatorServicesConfig.publishing), service configuration, Daemon stage/action, and a new remote side effect.

Verification

  • Targeted checks for the changed files / closest tests
  • pnpm typecheck for affected packages (TypeScript changes)
  • Module tests for affected areas (P1+)
  • Production build (only when touching compile boundaries, lazy loading, assets, or build config)
  • Service restart + health check (P2 daemon/runtime changes)
  • Web/Desktop parity checks (shared UI, Bridge, or packaging changes)

Actually ran:

  • pnpm --filter @opencreator/protocol build && pnpm --filter @opencreator/protocol test: 24 passed.
  • pnpm --filter @opencreator/daemon typecheck and pnpm --filter @opencreator/web typecheck: clean.
  • New apps/daemon/test/unit/creator-stickman-social-publish.test.ts (11 tests):
    • multipart body and headers, including Apikey auth and Idempotency-Key;
    • result normalization, and error / 404 mapping;
    • user-only confirmation, stale manifest rejected, socialPublish patch forgery rejected;
    • a stage run publishes once, and a second run with the same confirmation is refused without calling Upload-Post;
    • a dropped connection leads to lookup and no re-send;
    • unknown outcome → unknown_remote_acceptance;
    • a 401 can be retried with the same confirmation;
    • preflight blocked/ready;
    • recovery lookup.
  • creator-services-config-store.test.ts: defaults for older configs, key kept in credentials.json, redaction and retention.
  • Web:
    • new StickmanSocialPublishPanel.test.tsx (config link, confirm dialog gating, results, running lock);
    • StickmanVideoWorkspace.test.tsx (publishing dispatches confirm-social-publish then run-stage social-publish only after confirming);
    • CreatorServicesSettingsView.test.tsx (Publishing tab saves key and profile);
    • routes.test.ts.
  • Full suites:
    • apps/web: 1218 passed.
    • apps/daemon: 1388 passed, 1 failed. The failure is creator-download-executor.test.ts › preserves a non-ASCII output filename split across stdout chunks, which also fails on a clean master on my macOS machine, so it is unrelated.
  • Real end-to-end run through the actual daemon code: repository + createCreatorStageRunner + upload-post-publish executor + real HTTPS client against the production Upload-Post API, with a synthetic 1080×1920 H.264/AAC video on a test profile, TikTok SELF_ONLY and YouTube private:
    confirmation 0b82c195-1577-4d7f-ab48-bc9a5fe48f08
    stage succeeded  17s
    job completed ledger upload-post:succeeded:0b82c195-1577-4d7f-ab48-bc9a5fe48f08
    results: youtube completed (https://www.youtube.com/watch?v=6AGf2Wvd9kw, private)
             tiktok  completed (post id v_pub_file~v2-1.7691066215736920086)
    

Intentionally skipped, and why:

  • Production build, Desktop packaging and the Web/Desktop parity spec. The change adds no Host Bridge, Preload/IPC, platform capability, lazy chunk or build config, and the new UI is shared Web code with no platform-specific entry.
  • A daemon restart / health check, and clicking through the full app. I didn't run a live Codex runtime locally. The E2E above exercises the same stage runner, executor, ledger and HTTP client that the daemon wires up in server.ts.

Web / Desktop parity

  • This change does not affect Web/Desktop parity
  • General capability implemented once in shared Web/Daemon code (no per-bridge forks)
  • Platform-specific entries are hidden when the capability is unavailable (no silent no-op buttons)
  • The same flow was checked on the other platform, not only the one where the issue was reported

Checklist

  • Focused change — no unrelated refactors or fixes mixed in
  • Docs updated in the same PR if user-visible behavior changed
  • No .runtime/, credentials, Codex sessions, build caches, or user data committed

…load-Post

Adds an optional social-publish stage to the stickman-video template so a
finished delivery can be posted to TikTok, Instagram, YouTube, LinkedIn,
Facebook, X, Threads and Bluesky after an explicit user confirmation.

- Settings: new AI Services > Publishing category (Upload-Post API key +
  profile); the key is stored with the other service credentials.
- Daemon: user-only confirm-social-publish action; the stage publishes one
  confirmation at most once (request id = Idempotency-Key + provider ledger),
  polls per-platform results, never re-sends the file after a dropped
  connection, and records a social_publish_result artifact. Preflight blocks
  it until publishing is configured.
- Web: publish panel on the Video delivery step with platform choice, editable
  copy, YouTube private by default, AI-content label and a confirm dialog.
- Docs: docs/social-publishing.md.

@wulien wulien left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution and for disclosing the Upload-Post affiliation. I reviewed the user-confirmed publishing flow, credential storage/redaction, provider request recovery, and the actual integration against master bfbf59a8375ae8c11decb3ebd4ea5e5a7be741de.

The optional stage, shared Web/Daemon implementation, private YouTube default, and separate credential storage are good boundaries. Head 5d6b5a8e2abb0d8dc2865d3686caf9c751eae9c5 integrates without conflicts or unrelated commits. However, two reproduced behavior issues block approval.

Blocking issues

1. HTTP 5xx is treated as definitive rejection and permits resubmission without a new confirmation

In apps/daemon/src/creator/stickman/social-publish-executor.ts:119, every UploadPostApiError calls ledger.markFailed(...). The HTTPS client also wraps HTTP 500/502/503 responses in that class. A failed ledger entry is not in acceptedLedgerStatuses, so retrying the stage sends the video again with the same confirmation without querying remote acceptance first.

Evidence:
Using the actual repository, service, stage runner, and executor, I injected UploadPostApiError('upload_post_http_error', ..., 503) for the first submission and then retried the stage without another confirm-social-publish. The regression assertion expected one submission and received two. The existing definitive-401 rejection test passes, but does not cover this ambiguous outcome.

Impact:
A gateway/server failure does not establish that nothing was created remotely. This violates the PR's stated “file is never re-sent” recovery contract and bypasses the durable local ambiguity gate. The provider's idempotency header is useful but is not a substitute for that gate; its documentation describes a 24-hour duplicate-protection window.

Required change:
Only record a definitive pre-acceptance rejection as failed. Treat ambiguous 5xx/transport/invalid-response outcomes as potentially accepted: look up the request ID, continue tracking an existing request, or retain unknown_remote_acceptance and require the existing explicit resolution flow. Add tests proving that the same confirmation never submits a second file after an ambiguous HTTP failure, including after recovery/restart.

2. A technical draft with blocking checks can be published through the ordinary finished-delivery flow

apps/daemon/src/creator/stickman/action-handler.ts:96 checks the latest manifest's artifact status and ID, but not its delivery contents. social-publish-executor.ts:52 repeats only the artifact/ID checks. Existing delivery generation deliberately emits a completed manifest with packageStatus: 'technical-draft' when placeholder assets or blocking checks remain.

On the Web side, StickmanSocialPublishPanel.tsx:97 enables publishing based on a manifest ID and form validity. ResultStep renders the publishing panel whether or not its existing publishable check passes, and the confirmation dialog does not mention the unresolved checks/placeholders.

Evidence:
I constructed a manifest accepted by stickmanDeliveryManifestSchema, with packageStatus: 'technical-draft', a placeholder asset, and blockingChecks: ['visual_ocr_unverified']. The user confirmation was accepted and running the stage invoked the publish client once. The regression expected rejection and zero submissions.

Impact:
The new public side effect bypasses the pipeline's explicit distinction between a publishable package and a technical draft. A generic platform-selection confirmation does not acknowledge the actual unresolved publishing checks.

Required change:
Validate the confirmed delivery contents in the Daemon and block technical drafts/unresolved checks by default; mirror this state in the Web publishing entry. If publishing drafts is an intentional supported capability, define a separate explicit override that presents and records the specific outstanding risks instead of silently treating them as a normal finished delivery. Add backend and UI regression coverage.

Verification performed

  • Reviewed the exact head and simulated its merge into current master; the resulting tree equals the PR head tree.
  • In an isolated archive of that exact head, the supplied targeted Daemon tests pass: 37/37, covering publishing and service credential configuration.
  • The targeted Web publishing panel, Stickman workspace, publishing settings, and route tests pass: 62/62. React act(...) warnings were present; no unrelated test changes were made.
  • Two additional maintainer regression probes fail with the concrete outcomes above. These probes only use injected clients; no real social publishing occurred.

Remaining integration gate

GitHub currently reports no checks for this head. CI run 36631396111 has conclusion action_required, so it is awaiting maintainer action rather than providing passing CI evidence. After the behavior fixes, the applicable checks must run for the updated head before this P2 remote-side-effect change can be merged.

I acknowledge the private TikTok/YouTube production smoke evidence supplied in the PR description. I did not independently repeat that publish, run package typechecks, start/restart the Daemon, perform Web/Desktop parity or packaged App verification, or invoke the merge-commit verification handoff. No merge commit or master update was created.

…nical drafts

- Only a definitive pre-acceptance rejection (HTTP 4xx other than 408) marks the
  Upload-Post request failed and allows retrying the same confirmation. 5xx,
  transport errors and unreadable responses look the request id up: a known
  request keeps being tracked, otherwise the ledger stays
  unknown_remote_acceptance for the existing resolution flow.
- confirm-social-publish and the social-publish stage now read the confirmed
  delivery manifest and refuse technical drafts, placeholder assets and
  unresolved blocking checks, naming them. The Web panel mirrors the verdict
  and shows the outstanding checks instead of a publish button.
- Regression tests for both, including a daemon restart after an ambiguous
  503 and the technical-draft + placeholder + visual_ocr_unverified case.
@mutonby

mutonby commented Sep 30, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review and the reproductions. Both issues are fixed in f5e30fd.

1. Ambiguous HTTP failures no longer allow a resubmission

  • Only a definitive pre-acceptance rejection (HTTP 4xx other than 408) marks the ledger entry failed and lets the same confirmation be retried. That is the "fix the API key and retry" case.
  • 5xx responses, transport errors and unreadable responses now go through a request-id lookup on /api/uploadposts/status:
    • if Upload-Post knows the request, the stage marks it waiting_remote and keeps tracking it;
    • if not, or if the lookup fails, it stays unknown_remote_acceptance for the existing explicit resolution flow.
  • The local gate stays authoritative; the provider's Idempotency-Key is only a second layer.
  • Tests in creator-stickman-social-publish.test.ts:
    • never submits the same confirmation twice after an ambiguous 5xx, even after a restart: an injected 503 with no remote record leads to unknown_remote_acceptance. A retry with the same confirmation is refused. A fresh ledger/runner/executor on the same database then recovers the request via createUploadPostProviderCapabilities and is still refused. submitVideo is called exactly once.
    • keeps tracking a request that Upload-Post accepted despite a 5xx answer: a 502 on submit, the request is found by id, polling runs to completion, and there is one submission.
    • The existing 401 test still covers the retryable, definitive-rejection path.

2. Technical drafts can no longer be published

  • A new checkPublishableDelivery reads the confirmed manifest with stickmanDeliveryManifestSchema. It rejects packageStatus: 'technical-draft', any placeholderAssets and any blockingChecks, naming each one (creator_social_publish_delivery_not_publishable).
  • It runs in both confirm-social-publish and the social-publish stage, so a confirmation already stored in state cannot publish a draft either.
  • Web: the workspace passes the manifest verdict to the panel. For a draft, the panel lists the outstanding items (technical draft, placeholder assets, unresolved checks) and explains that the delivery has to be regenerated, with no platform form and no publish button. While the manifest is still loading, publishing stays disabled.
  • I didn't add an override: drafts are simply blocked.
  • Tests:
    • Daemon: refuses to publish a technical draft with placeholders and unresolved checks, your exact case (technical-draft + placeholder shot-02 + blockingChecks: ['visual_ocr_unverified']). The confirmation is rejected and nothing is written to state. A confirmation injected directly into state also fails the stage, with 0 submitVideo calls and no ledger entry.
    • Web: StickmanSocialPublishPanel.test.tsx (the same draft case shows the three blockers with no publish button or form; disabled until the verdict loads) and StickmanVideoWorkspace.test.tsx (does not offer publishing for a technical draft delivery; the publish flow test now uses a publishable manifest).

I checked that the three new regressions fail against 5d6b5a8's executor and action handler, and pass with the fix.

Verification on f5e30fd

  • pnpm --filter @opencreator/daemon typecheck and pnpm --filter @opencreator/web typecheck: clean.
  • apps/web: 1221/1221.
  • apps/daemon: 1390 passed, 2 failed, both unrelated:
    • creator-download-executor › preserves a non-ASCII output filename split across stdout chunks also fails on a clean master on my macOS machine;
    • codex-runner › bounds diagnostic output… failed once under full-suite load and passes 3/3 when run on its own.
  • Real private run against the production API with the same stage runner, executor and HTTPS client, and a valid publishable manifest:
    • TikTok SELF_ONLY was published.
    • YouTube came back failed with Upload-Post's account_restricted (the test channel had reached YouTube's daily upload limit), and the result was recorded as partial with that message.
    • Re-running the stage with the same confirmation was refused with creator_social_publish_already_submitted, and nothing was sent again.

docs/social-publishing.md now describes both rules. The CI run for f5e30fd (36700687215) is action_required, waiting for maintainer approval to run the workflows.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants