Merge pull request #124 from R0m1k3/claude/practical-newton-aal4xl

Reels : option « Publier aussi en story » sur Facebook
This commit is contained in:
LogiFlow authored and GitHub committed 2026-10-05 09:03:19 +02:00
commit 01315bf281
10 files changed
+172 -5

No files matched your search

@@ -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 (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState<string | null>(null); const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée // 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 [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText, description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
stabilize: stabilize, stabilize: stabilize,
drawText: true, drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook Pages Facebook
</div> </div>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
+4
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider"; import { Slider } from "@/components/ui/slider";
import { import {
Select, Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true); const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée // 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 [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText, description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(), scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100, musicVolume: musicVolume[0] / 100,
ttsEnabled, ttsEnabled,
ttsEngine, ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook Pages Facebook
</div> </div>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
+4
View File
@@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema"; import type { SocialPage, Media } from "@shared/schema";
@@ -30,6 +31,7 @@ export default function ScheduleReel() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState<string[]>([]); const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined); const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] }); const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] }); const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
@@ -83,6 +85,7 @@ export default function ScheduleReel() {
mediaIds: [selectedVideo!.id], mediaIds: [selectedVideo!.id],
pageIds: selectedPages, pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(), scheduledFor: scheduledDate!.toISOString(),
alsoStory,
}); });
return response.json(); return response.json();
}, },
@@ -250,6 +253,7 @@ export default function ScheduleReel() {
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook <SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
</Label> </Label>
{facebookPages.map(renderTargetCheckbox)} {facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div> </div>
)} )}
{tiktokAccounts.length > 0 && ( {tiktokAccounts.length > 0 && (
+13 -1
View File
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try { try {
const user = req.user as User; const user = req.user as User;
const userId = user.id; 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 } // Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = []; let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -1395,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
postType: finalPostType, postType: finalPostType,
scheduledAt, 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,
});
}
}
} }
} }
} }
+55
View File
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId; 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<string> {
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 * Publie une vidéo comme Reel Facebook
* *
+59 -3
View File
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`). * cas de l'ancien `publishReel(originalUrl)`).
*/ */
import type { Media } from "@shared/schema"; import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage"; import { storage } from "../../storage";
import { minioService } from "../minio"; import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail"; import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean; success: boolean;
reelId?: string; reelId?: string;
error?: 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. */ /** 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 * Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post. * `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: { export async function publishReelToPages(options: {
postId: string; postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer; videoBuffer: Buffer;
description: string; description: string;
scheduledFor?: string; scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> { }): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor } = options; const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = []; const results: PagePublishResult[] = [];
for (const pageId of pageIds) { for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel", postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(), scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
}); });
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) { if (scheduledFor) {
// Le planificateur publiera à l'heure dite // 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" }); results.push({ pageId, success: true, reelId: "scheduled" });
continue; continue;
} }
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(), publishedAt: new Date(),
externalPostId: reelId, 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 { } else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme // TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera // effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results; 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<string | undefined> {
// 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é. */ /** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string { export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; "); const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
+1
View File
@@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer, videoBuffer,
description: params.description || params.overlayText || "", description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor, scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
}); });
if (!results.some((r) => r.success)) { if (!results.some((r) => r.success)) {
+1 -1
View File
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => { it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] }); 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", () => { it("accepte les anciens paramètres encore stockés dans des jobs", () => {
+2
View File
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(), ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(), ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText, 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 // Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(), wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64), fontSize: z.number().int().min(16).max(200).default(64),