diff --git a/client/src/components/reels/story-toggle.tsx b/client/src/components/reels/story-toggle.tsx
new file mode 100644
index 0000000..7c3c95f
--- /dev/null
+++ b/client/src/components/reels/story-toggle.tsx
@@ -0,0 +1,29 @@
+import { Switch } from "@/components/ui/switch";
+
+interface StoryToggleProps {
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}
+
+/**
+ * Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a
+ * pas de stories : l'option ne le concerne pas.
+ */
+export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) {
+ return (
+
+
+
onCheckedChange(value === true)}
+ data-testid="switch-reel-also-story"
+ />
+
+ );
+}
diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx
index 47f7cbd..a87972f 100644
--- a/client/src/pages/mobile/new-reel.tsx
+++ b/client/src/pages/mobile/new-reel.tsx
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
+import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
+ const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
+ alsoStory,
musicVolume: musicVolume[0] / 100,
stabilize: stabilize,
drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook
{facebookPages.map(renderTargetCheckbox)}
+
)}
diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx
index e1102c5..db1c737 100644
--- a/client/src/pages/new-reel.tsx
+++ b/client/src/pages/new-reel.tsx
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
+import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider";
import {
Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
+ const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
+ alsoStory,
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook
{facebookPages.map(renderTargetCheckbox)}
+
)}
diff --git a/client/src/pages/schedule-reel.tsx b/client/src/pages/schedule-reel.tsx
index ad68f64..bb0ae7d 100644
--- a/client/src/pages/schedule-reel.tsx
+++ b/client/src/pages/schedule-reel.tsx
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
+import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
@@ -30,6 +31,7 @@ export default function ScheduleReel() {
const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState([]);
const [scheduledDate, setScheduledDate] = useState(undefined);
+ const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery({ queryKey: ["/api/media"] });
@@ -83,6 +85,7 @@ export default function ScheduleReel() {
mediaIds: [selectedVideo!.id],
pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(),
+ alsoStory,
});
return response.json();
},
@@ -250,6 +253,7 @@ export default function ScheduleReel() {
Pages Facebook
{facebookPages.map(renderTargetCheckbox)}
+
)}
{tiktokAccounts.length > 0 && (
diff --git a/server/routes.ts b/server/routes.ts
index 8979701..85aa358 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise {
try {
const user = req.user as User;
const userId = user.id;
- const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
+ const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -1395,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise {
postType: finalPostType,
scheduledAt,
});
+ // Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas
+ if (finalPostType === 'reel' && alsoStory === true) {
+ const page = await storage.getSocialPage(pageId);
+ if (page?.platform === 'facebook') {
+ await storage.createScheduledPost({
+ postId: post.id,
+ pageId,
+ postType: 'story',
+ scheduledAt,
+ });
+ }
+ }
}
}
}
diff --git a/server/services/facebook.ts b/server/services/facebook.ts
index ab89979..e9edc4e 100644
--- a/server/services/facebook.ts
+++ b/server/services/facebook.ts
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId;
}
+ /**
+ * Publie une vidéo en story Facebook en envoyant directement les données
+ * binaires (start → upload → finish sur /video_stories). Sert à doubler un
+ * Reel d'une story : la vidéo rendue est déjà en mémoire.
+ */
+ async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise {
+ if (!page.accessToken) throw new Error('No access token found for this page');
+ const accessToken = page.accessToken;
+ const fileSize = videoBuffer.length;
+
+ const startParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'start',
+ });
+ const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
+ method: 'POST',
+ });
+ if (!startRes.ok) {
+ const error = await startRes.json() as FacebookError;
+ throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+ const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
+
+ const uploadRes = await fetch(uploadUrl, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `OAuth ${accessToken}`,
+ 'Content-Type': 'application/octet-stream',
+ 'offset': '0',
+ 'file_size': fileSize.toString(),
+ },
+ body: new Blob([videoBuffer], { type: 'video/mp4' }),
+ });
+ if (!uploadRes.ok) {
+ throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
+ }
+
+ const finishParams = new URLSearchParams({
+ access_token: accessToken,
+ upload_phase: 'finish',
+ video_id: videoId,
+ });
+ const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
+ method: 'POST',
+ });
+ if (!finishRes.ok) {
+ const error = await finishRes.json() as FacebookError;
+ throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
+ }
+
+ const finishData = await finishRes.json() as { success: boolean; post_id?: string };
+ console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
+ return finishData.post_id || videoId;
+ }
+
/**
* Publie une vidéo comme Reel Facebook
*
diff --git a/server/services/reels/publish.ts b/server/services/reels/publish.ts
index 0a7a084..bc88dbe 100644
--- a/server/services/reels/publish.ts
+++ b/server/services/reels/publish.ts
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`).
*/
-import type { Media } from "@shared/schema";
+import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean;
reelId?: string;
error?: string;
+ /** Échec de la story associée : n'empêche pas le Reel d'être publié. */
+ storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
@@ -49,6 +51,9 @@ export async function storeRenderedVideo(
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
+ *
+ * Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
+ * n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
+ alsoStory?: boolean;
}): Promise {
- const { postId, pageIds, videoBuffer, description, scheduledFor } = options;
+ const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
+ const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
+ if (withStory) {
+ await storage.createScheduledPost({
+ postId,
+ pageId: page.id,
+ postType: "story",
+ scheduledAt: new Date(scheduledFor),
+ });
+ }
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(),
externalPostId: reelId,
});
- results.push({ pageId, success: true, reelId });
+ const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
+ results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results;
}
+/**
+ * Publie la story d'une page juste après son Reel : un échec est consigné sur
+ * son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
+ * échouer le Reel.
+ */
+async function publishStory(
+ postId: string,
+ page: SocialPage,
+ videoBuffer: Buffer,
+): Promise {
+ // Créée seulement maintenant : en attente pendant l'envoi du Reel, le
+ // planificateur aurait pu la publier une seconde fois.
+ let scheduledStoryId: string | undefined;
+ try {
+ ({ id: scheduledStoryId } = await storage.createScheduledPost({
+ postId,
+ pageId: page.id,
+ postType: "story",
+ scheduledAt: new Date(),
+ }));
+ const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
+ await storage.updateScheduledPost(scheduledStoryId, {
+ publishedAt: new Date(),
+ externalPostId: storyId,
+ });
+ return undefined;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Erreur inconnue";
+ console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
+ if (scheduledStoryId) {
+ // Marquée comme traitée pour que le planificateur ne la republie pas en boucle
+ await storage.updateScheduledPost(scheduledStoryId, {
+ publishedAt: new Date(),
+ error: message,
+ }).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
+ }
+ return message;
+ }
+}
+
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
diff --git a/server/services/reels/videoPipeline.ts b/server/services/reels/videoPipeline.ts
index 44f5a77..7bce929 100644
--- a/server/services/reels/videoPipeline.ts
+++ b/server/services/reels/videoPipeline.ts
@@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
+ alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
diff --git a/shared/reel.test.ts b/shared/reel.test.ts
index 93a5bea..ec7a826 100644
--- a/shared/reel.test.ts
+++ b/shared/reel.test.ts
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] });
- expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true });
+ expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true, alsoStory: false });
});
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
diff --git a/shared/reel.ts b/shared/reel.ts
index 1946bc6..d2166d1 100644
--- a/shared/reel.ts
+++ b/shared/reel.ts
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText,
+ // Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok)
+ alsoStory: z.boolean().default(false),
// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64),