diff --git a/attached_assets/image_1763735384881.png b/attached_assets/image_1763735384881.png
new file mode 100644
index 0000000..0d6f5d6
Binary files /dev/null and b/attached_assets/image_1763735384881.png differ
diff --git a/client/src/components/emoji-text.tsx b/client/src/components/emoji-text.tsx
new file mode 100644
index 0000000..bc84a62
--- /dev/null
+++ b/client/src/components/emoji-text.tsx
@@ -0,0 +1,42 @@
+import { useMemo } from 'react';
+import { parseTextWithEmojis } from '@/lib/emojiUtils';
+
+interface EmojiTextProps {
+ text: string;
+ className?: string;
+ emojiSize?: number;
+}
+
+/**
+ * Component that renders text with emojis as Twemoji images
+ * Matches the backend Twemoji rendering for consistent preview
+ */
+export function EmojiText({ text, className = '', emojiSize = 20 }: EmojiTextProps) {
+ const segments = useMemo(() => parseTextWithEmojis(text), [text]);
+
+ return (
+
+ {segments.map((segment, idx) => {
+ if (segment.type === 'text') {
+ return {segment.content};
+ } else {
+ return (
+
+ );
+ }
+ })}
+
+ );
+}
diff --git a/client/src/components/preview-modal.tsx b/client/src/components/preview-modal.tsx
index 7fcd3e9..1f4b1be 100644
--- a/client/src/components/preview-modal.tsx
+++ b/client/src/components/preview-modal.tsx
@@ -5,6 +5,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { ChevronLeft, ChevronRight, Send, Facebook, Instagram, Heart, MessageCircle, Share2, Bookmark } from 'lucide-react';
import type { Media } from '@shared/schema';
import { removeHashtags } from '@shared/emoji';
+import { EmojiText } from '@/components/emoji-text';
interface PreviewModalProps {
open: boolean;
@@ -425,7 +426,9 @@ export function PreviewModal({
}}
>
{lines.map((line, idx) => (
-
{line}
+
+
+
))}
diff --git a/client/src/lib/emojiUtils.ts b/client/src/lib/emojiUtils.ts
new file mode 100644
index 0000000..9203c31
--- /dev/null
+++ b/client/src/lib/emojiUtils.ts
@@ -0,0 +1,81 @@
+import twemoji from 'twemoji';
+
+export interface EmojiSegment {
+ type: 'text' | 'emoji';
+ content: string;
+ url?: string;
+}
+
+/**
+ * Parse text and identify emoji segments
+ * Returns an array of segments with text and emoji parts
+ */
+export function parseTextWithEmojis(text: string): EmojiSegment[] {
+ const segments: EmojiSegment[] = [];
+ let lastIndex = 0;
+
+ // Use twemoji to parse and replace emojis
+ const parsed = twemoji.parse(text, {
+ callback: (icon: string, options: any) => {
+ return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${icon}.png`;
+ }
+ });
+
+ // Extract emoji positions using a temporary div
+ const tempDiv = document.createElement('div');
+ tempDiv.innerHTML = parsed;
+
+ const walker = document.createTreeWalker(
+ tempDiv,
+ NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
+ null
+ );
+
+ let node;
+ let currentText = '';
+
+ while ((node = walker.nextNode())) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ if (node.textContent) {
+ currentText += node.textContent;
+ }
+ } else if (node.nodeType === Node.ELEMENT_NODE && (node as Element).tagName === 'IMG') {
+ // Push any accumulated text before this emoji
+ if (currentText) {
+ segments.push({ type: 'text', content: currentText });
+ currentText = '';
+ }
+
+ const img = node as HTMLImageElement;
+ segments.push({
+ type: 'emoji',
+ content: img.alt || '',
+ url: img.src
+ });
+ }
+ }
+
+ // Push any remaining text
+ if (currentText) {
+ segments.push({ type: 'text', content: currentText });
+ }
+
+ // If no emojis were found, return the original text as a single segment
+ if (segments.length === 0) {
+ return [{ type: 'text', content: text }];
+ }
+
+ return segments;
+}
+
+/**
+ * Convert text with emojis to HTML string with Twemoji images
+ */
+export function textToTwemojiHtml(text: string, className?: string): string {
+ return twemoji.parse(text, {
+ className: className || 'inline-block align-middle',
+ callback: (icon: string, options: any) => {
+ return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${icon}.png`;
+ }
+ });
+}
diff --git a/package-lock.json b/package-lock.json
index 6500294..30474dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -86,6 +86,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5",
+ "twemoji": "^14.0.2",
"twemoji-parser": "^14.0.0",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
@@ -5893,6 +5894,29 @@
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/fs-extra/node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -6037,8 +6061,7 @@
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
- "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
- "dev": true
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
@@ -6325,6 +6348,18 @@
"node": ">=6"
}
},
+ "node_modules/jsonfile": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
+ "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^0.1.2"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
@@ -8715,6 +8750,18 @@
"url": "https://github.com/sponsors/Wombosvideo"
}
},
+ "node_modules/twemoji": {
+ "version": "14.0.2",
+ "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
+ "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
+ "license": "MIT",
+ "dependencies": {
+ "fs-extra": "^8.0.1",
+ "jsonfile": "^5.0.0",
+ "twemoji-parser": "14.0.0",
+ "universalify": "^0.1.2"
+ }
+ },
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
@@ -8772,6 +8819,15 @@
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"license": "MIT"
},
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
diff --git a/package.json b/package.json
index 2ea2cc0..27c6803 100644
--- a/package.json
+++ b/package.json
@@ -88,6 +88,7 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.2.5",
+ "twemoji": "^14.0.2",
"twemoji-parser": "^14.0.0",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
diff --git a/replit.md b/replit.md
index 5f7ef7a..edad4b8 100644
--- a/replit.md
+++ b/replit.md
@@ -9,13 +9,19 @@ Preferred communication style: Simple, everyday language.
## Recent Changes
### November 21, 2025
-- **Story Text Rendering with Emoji Support**: Implemented robust emoji rendering in Instagram/Facebook stories using Twemoji (Twitter's emoji library). Previous implementation removed all emojis due to canvas rendering limitations. New solution:
+- **Story Text Rendering with Emoji Support (Backend & Frontend)**: Implemented complete emoji rendering pipeline for Instagram/Facebook stories using Twemoji:
+ - **Backend Implementation** (`server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`):
+ - Created `emojiRenderer` service that converts text emojis to high-quality PNG images from Twemoji CDN
+ - Smart text wrapping handles long emoji sequences (e.g., "🎉🎉🎉🎉🎉") by breaking them into chunks that fit within canvas bounds
+ - In-memory caching of emoji images reduces CDN requests and improves performance
+ - Grapheme-aware breaking uses `Array.from()` for proper Unicode grapheme splitting
+ - **Frontend Preview Implementation** (`client/src/lib/emojiUtils.ts`, `client/src/components/emoji-text.tsx`):
+ - Added `twemoji` package for client-side emoji parsing
+ - Created `EmojiText` React component that renders emojis as Twemoji images in preview modal
+ - Ensures preview modal matches actual published story appearance (both use Twemoji CDN images)
+ - Emoji size dynamically scales with text font size
- **Hashtag Removal**: Changed from removing emojis to removing hashtags (`#tag`) for cleaner story text while preserving emojis for engagement
- - **Twemoji Integration**: Created `emojiRenderer` service that converts text emojis to high-quality PNG images from Twemoji CDN
- - **Smart Text Wrapping**: Enhanced text wrapping to handle long emoji sequences (e.g., "🎉🎉🎉🎉🎉") by breaking them into chunks that fit within canvas bounds
- - **Emoji Caching**: Implements in-memory caching of emoji images to reduce CDN requests and improve performance
- - **Grapheme-Aware Breaking**: Uses `Array.from()` for proper Unicode grapheme splitting, ensuring multi-codepoint emojis aren't broken incorrectly
- - Files modified: `shared/emoji.ts`, `server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`, `client/src/components/preview-modal.tsx`
+ - Files modified: `shared/emoji.ts`, `server/services/emojiRenderer.ts`, `server/services/imageProcessor.ts`, `client/src/lib/emojiUtils.ts`, `client/src/components/emoji-text.tsx`, `client/src/components/preview-modal.tsx`
### November 18, 2025
- **Facebook Video Codec Compatibility Fix**: Fixed issue where MP4 videos uploaded to Facebook would not play correctly due to codec incompatibility. Facebook requires strict video format: H.264 video codec, AAC audio codec, and MP4 container. Solution: Added automatic video transcoding via Cloudinary transformations. When publishing a video to Facebook, the system now transforms the Cloudinary URL to include `vc_h264,ac_aac,f_mp4,q_auto` parameters, ensuring all videos are automatically re-encoded to Facebook-compatible format before publication. The getFacebookCompatibleVideoUrl() method detects Cloudinary video URLs and applies the necessary transformations transparently. Non-Cloudinary URLs are passed through unchanged with a warning. This ensures reliable video playback on Facebook without requiring users to manually re-encode their videos. Files modified: `server/services/facebook.ts`.