mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Reels : option « Publier aussi en story » sur Facebook
Une case sur les écrans de création (bureau, mobile) et de programmation d'un Reel double la publication d'une story vidéo sur les pages Facebook sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas. - Publication immédiate : la story part juste après le Reel, à partir de la vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer). Un échec de la story est noté dans l'historique sans faire échouer le Reel. - Publication programmée : une entrée « story » est planifiée à côté du Reel ; le planificateur la publie comme les autres stories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [alsoStory, setAlsoStory] = useState(false);
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ 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() {
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
|
||||
</Label>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
|
||||
</div>
|
||||
)}
|
||||
{tiktokAccounts.length > 0 && (
|
||||
|
||||
+13
-1
@@ -1268,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
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<Server> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
*
|
||||
|
||||
@@ -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<PagePublishResult[]> {
|
||||
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<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é. */
|
||||
export function describePublishFailure(results: PagePublishResult[]): string {
|
||||
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user