Toutes les ressources
Photoshopoct. 2026

Social Safe Zones

Sélectionnez un format, le script vous créé le document à la bonne taille avec toutes les zones sûres.

martinjude.fr/freebies/SocialSafeZones
SocialSafeZones.jsxTélécharger
/*
<javascriptresource>
<name>Social Safe Zones...</name>
<category>social</category>
<about>Creates a social media canvas with safe-zone overlays, guides and a UI mockup.</about>
</javascriptresource>
*/

/*
 * Social Safe Zones - Photoshop script (ExtendScript, Photoshop 2020+)
 *
 * Pick one or more social media formats in a dialog; for each one the script creates:
 *   - the canvas (exact pixel size, 72 ppi, sRGB)
 *   - guides on the safe area and crop frames
 *   - low-opacity shape layers marking areas covered by the platform UI, areas that may be
 *     covered, and areas cropped in some views (profile grid, desktop/mobile, avatar circle)
 *   - an optional mockup of the platform UI (shape + text layers)
 *   - an ARTWORK group, selected, ready for your design
 *
 * Install: copy this file to  Photoshop/Presets/Scripts/  and restart Photoshop
 *          (File > Scripts > Social Safe Zones...), or run it via File > Scripts > Browse...
 *
 * Every size and zone lives in the FORMAT LIBRARY below. Platforms change their UI often:
 * values marked "~" are approximations, adjust them freely.
 */

#target photoshop

(function () {

var VERSION = '1.0.0';
var PREFS_FILE = File(Folder.userData + '/SocialSafeZones.prefs');

// =============================================================================
// 1. FORMAT LIBRARY
// =============================================================================
// Pixel values at 72 ppi.
//   safe:  [top, right, bottom, left] margins of the safe area (green frame + guides)
//   safeLabel: corner of the safe-area label, 'bl' (default) | 'br' | 'tl' | 'tr'
//   zones: overlays, built with the helpers below. Zone kinds:
//          'danger'  (red)    covered by the platform UI
//          'caution' (orange) may be covered (hover, ads, some devices)
//          'crop'    (blue)   cropped in some views
//   mockup: [builder, config] for the UI mockup (see section 5)

var APPROX = '\u2248';
var DOT = ' \u00B7 ';
var TIMES = '\u00D7';

function band(side, size, label, k, from, to) {
    return { t: 'band', side: side, size: size, label: label, k: k || 'danger', from: from, to: to };
}
function top(size, label, k, from, to) { return band('top', size, label, k, from, to); }
function bottom(size, label, k, from, to) { return band('bottom', size, label, k, from, to); }
function left(size, label, k, from, to) { return band('left', size, label, k, from, to); }
function right(size, label, k, from, to) { return band('right', size, label, k, from, to); }
function box(x, y, w, h, label, k) { return { t: 'rect', r: [x, y, w, h], label: label, k: k || 'danger' }; }
function oval(x, y, w, h, label, k) { return { t: 'ellipse', r: [x, y, w, h], label: label, k: k || 'danger' }; }
// Everything outside a circle (default: largest centred circle)
function circleCrop(label, r) { return { t: 'outside', r: r, label: label || 'Cropped (circle)', k: 'danger' }; }
// Centred crop to an aspect ratio, e.g. crop([3, 4], 'Profile grid')
function crop(ratio, label, tint, lp) { return { t: 'crop', ratio: ratio, label: label, tint: tint !== false, lp: lp }; }
// Explicit crop frame; lp = label corner: 'tl' | 'tr' | 'bl' | 'br'
function cropRect(x, y, w, h, label, tint, lp) { return { t: 'crop', r: [x, y, w, h], label: label, tint: !!tint, lp: lp }; }

// --- shared zone sets --------------------------------------------------------
var STORY_IG = [
    top(250, 'Profile, progress bar & close'),
    bottom(340, 'Reply bar, reactions & CTA'),
    left(60, 'Edge', 'caution', 250, 1580),
    right(60, 'Edge', 'caution', 250, 1580)
];
var REELS_META = [
    top(270, 'Header & camera'),
    bottom(670, 'Caption, profile, audio & ad CTA'),
    box(940, 900, 140, 350, 'Action buttons'),
    left(65, 'Edge', 'caution', 270, 1250),
    right(65, 'Edge', 'caution', 270, 900),
    crop([3, 4], 'Profile grid 3:4', false),
    crop([4, 5], 'Feed preview 4:5', false)
];
function pinZones(h) {
    return [
        box(690, 50, 260, 150, 'Save (hover)', 'caution'),
        box(50, h - 186, 520, 136, 'Link (hover)', 'caution'),
        box(670, h - 186, 280, 136, 'Share & more', 'caution')
    ];
}

// --- the library -------------------------------------------------------------
var LIB = [];
function add(platform, name, w, h, o) {
    o = o || {};
    o.platform = platform; o.name = name; o.w = w; o.h = h;
    o.zones = o.zones || [];
    o.id = (platform + '-' + name + '-' + w + 'x' + h).toLowerCase().replace(/[^a-z0-9]+/g, '-');
    LIB.push(o);
}

// ---------------------------------------------------------------- Instagram
add('Instagram', 'Story', 1080, 1920, {
    safe: [250, 60, 340, 60], zones: STORY_IG,
    mockup: ['story', { name: 'yourbrand', time: '2 h', right: ['close', 'more'], pill: 'Send message', icons: ['heart', 'send'] }],
    notes: 'Top 250 px: profile, username, progress bar. Bottom 340 px: reply field & reactions (taller for ads with a CTA). Video: up to 60 s per card, MP4 H.264.'
});
add('Instagram', 'Reel / Reel cover', 1080, 1920, {
    safe: [270, 140, 670, 65], zones: REELS_META,
    mockup: ['reel', 'instagram'],
    notes: 'Meta guidance: keep ~14% top, ~35% bottom and ~6% sides free of text & logos. Cover: the profile grid shows the centre 3:4 (1080' + TIMES + '1440), the feed preview 4:5 (1080' + TIMES + '1350).'
});
add('Instagram', 'Feed post portrait 4:5', 1080, 1350, {
    zones: [crop([3, 4], 'Profile grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 1262, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Best performing feed ratio. Since 2025 the profile grid is 3:4: the outer 34 px on each side are hidden on the grid. Carousels: every slide uses the size of the first one.'
});
add('Instagram', 'Feed post 3:4', 1080, 1440, {
    zones: [crop([4, 5], 'Feed 4:5 fallback ' + APPROX, false), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 1352, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Matches the 3:4 profile grid (no crop on the grid). Where 3:4 is not supported yet, the centre 4:5 is shown.'
});
add('Instagram', 'Feed post square', 1080, 1080, {
    zones: [crop([3, 4], 'Profile grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution'), oval(24, 992, 64, 64, '', 'caution')],
    mockup: ['igFeed'],
    notes: 'Square posts lose 135 px on each side on the 3:4 profile grid.'
});
add('Instagram', 'Feed post landscape', 1080, 566, {
    zones: [crop([3, 4], 'Grid 3:4'), box(956, 24, 100, 56, 'Carousel counter', 'caution')],
    mockup: ['igFeed'],
    notes: '1.91:1. Displayed small in the feed and heavily cropped on the profile grid: keep the subject in the centre.'
});
add('Instagram', 'Profile picture', 1080, 1080, {
    zones: [circleCrop()],
    notes: 'Displayed as a circle (110 px on mobile, 150 px on desktop), stored at 320' + TIMES + '320. Keep logos well inside the circle and readable at small sizes.'
});
add('Instagram', 'Highlight cover', 1080, 1920, {
    zones: [circleCrop('Outside the highlight circle', [0, 420, 1080, 1080])],
    notes: 'Highlights show the centre circle only. Keep the icon around 600 px wide, centred.'
});

// ---------------------------------------------------------------- Facebook
add('Facebook', 'Feed post square', 1080, 1080, { notes: '1:1, safe on every placement.' });
add('Facebook', 'Feed post portrait 4:5', 1080, 1350, { notes: 'Mobile feed displays up to 4:5. Desktop may show it smaller.' });
add('Facebook', 'Landscape / link', 1200, 630, { notes: '1.91:1, used for link previews and landscape posts.' });
add('Facebook', 'Story', 1080, 1920, {
    safe: [250, 60, 340, 60], zones: STORY_IG,
    mockup: ['story', { name: 'Your Page', time: '3 h', right: ['close', 'more'], pill: 'Reply\u2026', icons: ['thumb', 'heart'] }],
    notes: 'Same safe zones as Instagram Stories (Meta guidance: ~14% top, ~20% bottom).'
});
add('Facebook', 'Reel', 1080, 1920, {
    safe: [270, 140, 670, 65], zones: REELS_META.slice(0, 5),
    mockup: ['reel', 'facebook'],
    notes: 'Meta guidance for Reels: keep ~14% top, ~35% bottom and ~6% sides free of text & logos.'
});
add('Facebook', 'Cover photo', 1640, 924, {
    safe: [150, 60, 150, 60], safeLabel: 'br',
    zones: [cropRect(0, 150, 1640, 624, 'Desktop view (820' + TIMES + '312 @2x)', true), oval(64, 606, 336, 336, 'Profile photo ' + APPROX, 'caution')],
    mockup: ['avatar', { r: [64, 606, 336] }],
    notes: 'Upload 1640' + TIMES + '924 (2x). Mobile shows the full 16:9 image; desktop shows the centre 1640' + TIMES + '624 strip. Profile picture overlaps the bottom-left (' + APPROX + ').'
});
add('Facebook', 'Event cover', 1920, 1005, { notes: '1.91:1. Shown smaller in feeds and invites: keep text large.' });
add('Facebook', 'Group cover', 1640, 856, { notes: 'Displayed full width; sides can be trimmed on some mobile screens: keep content centred.' });
add('Facebook', 'Profile picture', 720, 720, { zones: [circleCrop()], notes: 'Displayed as a circle (176 px on desktop).' });

// ---------------------------------------------------------------- X / Twitter
add('X (Twitter)', 'Header', 1500, 500, {
    safe: [60, 40, 60, 40], safeLabel: 'br',
    zones: [top(60, 'May be cropped', 'crop'), bottom(60, 'May be cropped', 'crop'), oval(40, 324, 353, 353, 'Profile photo ' + APPROX)],
    mockup: ['avatar', { r: [40, 324, 353] }],
    notes: '3:1. Top & bottom can be cropped depending on screen width. The profile photo overlaps the bottom-left corner.'
});
add('X (Twitter)', 'Post landscape 16:9', 1600, 900, { notes: 'Single images display in full at 16:9 on desktop & mobile.' });
add('X (Twitter)', 'Post square', 1080, 1080, { notes: '1:1.' });
add('X (Twitter)', 'Post portrait 4:5', 1080, 1350, { notes: 'Tall images may be centre-cropped in the timeline on some clients; full image on tap.' });
add('X (Twitter)', 'Link card', 1200, 628, {
    zones: [box(24, 552, 340, 52, 'Domain label')],
    mockup: ['xCard'],
    notes: 'Summary card with large image. X overlays the domain name on the bottom-left of the image.'
});
add('X (Twitter)', 'Profile picture', 400, 400, { zones: [circleCrop()], notes: 'Circle for people, rounded square for verified organisations.' });

// ---------------------------------------------------------------- LinkedIn
add('LinkedIn', 'Profile banner (personal)', 1584, 396, {
    safe: [30, 40, 30, 540],
    zones: [oval(68, 177, 439, 439, '', 'caution'), oval(47, 175, 315, 315, 'Profile photo ' + APPROX)],
    mockup: ['avatar', { r: [47, 175, 315] }],
    notes: 'Personal profile (free & Premium). 4:1. Red = profile photo on desktop, orange = on the mobile app (' + APPROX + '). Put text & logos on the right two-thirds.'
});
add('LinkedIn', 'Company Page cover (business)', 1128, 191, {
    safe: [16, 24, 16, 200],
    zones: [box(34, 118, 146, 146, 'Logo ' + APPROX)],
    mockup: ['avatar', { r: [34, 118, 146], square: true }],
    notes: 'LinkedIn Page (company / showcase). The logo overlaps the bottom-left corner. Keep content centred-right.'
});
add('LinkedIn', 'Post landscape', 1200, 627, { notes: '1.91:1, also used for link previews.' });
add('LinkedIn', 'Post square', 1080, 1080, { notes: '1:1.' });
add('LinkedIn', 'Post portrait 4:5', 1080, 1350, { notes: '4:5, takes the most space in the mobile feed.' });
add('LinkedIn', 'Document carousel (PDF)', 1080, 1350, {
    zones: [top(80, 'Title bar (hover) ' + APPROX, 'caution'), bottom(80, 'Page counter & controls ' + APPROX, 'caution')],
    mockup: ['liDoc'],
    notes: 'Export as a multi-page PDF, one page per slide (4:5 or 1:1). LinkedIn overlays the title and page controls.'
});
add('LinkedIn', 'Vertical video', 1080, 1920, {
    safe: [200, 150, 500, 48],
    zones: [top(200, 'Header'), bottom(500, 'Author, caption & CTA ' + APPROX), box(930, 880, 150, 540, 'Action buttons ' + APPROX), left(48, 'Edge', 'caution', 200, 1420)],
    mockup: ['reel', 'linkedin'],
    notes: '9:16 vertical video feed. UI overlays are approximate.'
});
add('LinkedIn', 'Event cover', 1776, 444, { notes: '4:1.' });
add('LinkedIn', 'Article / newsletter cover', 1920, 1080, {
    zones: [crop([1.91, 1], 'Feed preview ' + APPROX + ' 1.91:1')],
    notes: '16:9. Feed previews crop it to ' + APPROX + '1.91:1.'
});
add('LinkedIn', 'Profile picture', 400, 400, { zones: [circleCrop()], notes: 'Personal profile photo, displayed as a circle.' });
add('LinkedIn', 'Company logo', 400, 400, { notes: 'LinkedIn Page logo, displayed as a square.' });

// ---------------------------------------------------------------- YouTube
add('YouTube', 'Thumbnail', 1280, 720, {
    zones: [box(1110, 628, 142, 64, 'Duration'), box(1180, 24, 76, 164, '', 'caution'), bottom(12, '', 'caution')],
    mockup: ['ytThumb'],
    notes: '16:9, max 2 MB. Bottom-right: duration badge. Top-right: hover buttons. Bottom: red progress bar once watched. Check legibility at 168' + TIMES + '94.'
});
add('YouTube', 'Channel banner', 2560, 1440, {
    safe: [508, 507, 509, 507],
    zones: [
        box(0, 0, 2560, 508, 'Visible on TV only', 'crop'), box(0, 931, 2560, 509, 'Visible on TV only', 'crop'),
        cropRect(0, 508, 2560, 423, 'Desktop max (2560' + TIMES + '423)', false, 'tl'),
        cropRect(352, 508, 1855, 423, 'Tablet (1855' + TIMES + '423)', false, 'tr')
    ],
    notes: 'TV shows the whole image. Desktop: centre strip 2560' + TIMES + '423. Text & logos: centre 1546' + TIMES + '423 (visible everywhere). Max 6 MB.'
});
add('YouTube', 'Video 16:9', 1920, 1080, {
    safe: [54, 96, 54, 96], safeLabel: 'br',
    zones: [top(100, 'Embed title & share', 'caution'), bottom(130, 'Player controls (hover)', 'caution'), cropRect(67, 38, 1786, 1004, 'Action safe 93%', false, 'tr')],
    mockup: ['ytPlayer'],
    notes: 'Green = title safe (90%). Player controls cover the bottom on hover; captions appear in the lower third.'
});
add('YouTube', 'End screen', 1920, 1080, {
    zones: [box(190, 330, 720, 405, 'Video element', 'caution'), box(1010, 330, 720, 405, 'Video element', 'caution'), oval(835, 770, 250, 250, 'Subscribe', 'caution')],
    mockup: ['ytEnd'],
    notes: 'Last 5\u201320 s of the video. Elements are placed in YouTube Studio; this is a common 2 videos + subscribe layout.'
});
add('YouTube', 'Shorts', 1080, 1920, {
    safe: [240, 160, 480, 48],
    zones: [
        top(240, 'Search, camera & menu'), bottom(480, 'Channel, title, subscribe & audio'),
        box(920, 860, 160, 580, 'Action buttons'),
        left(48, 'Edge', 'caution', 240, 1440), right(48, 'Edge', 'caution', 240, 860)
    ],
    mockup: ['reel', 'shorts'],
    notes: 'Up to 3 min. Keep text in the centre: the right rail and the bottom title area cover the video.'
});
add('YouTube', 'Profile picture', 800, 800, { zones: [circleCrop()], notes: 'Displayed as a circle down to 98' + TIMES + '98.' });
add('YouTube', 'Post image', 1080, 1080, { notes: 'Community / Posts tab image, 1:1.' });

// ---------------------------------------------------------------- TikTok
add('TikTok', 'Video', 1080, 1920, {
    safe: [160, 140, 480, 60],
    zones: [
        top(160, 'Tabs, LIVE & search'), bottom(480, 'Caption, sound & nav bar'),
        box(940, 760, 140, 680, 'Action buttons'),
        left(60, 'Edge', 'caution', 160, 1440), right(60, 'Edge', 'caution', 160, 760)
    ],
    mockup: ['reel', 'tiktok'],
    notes: 'Right rail and caption cover the video. The bottom nav bar overlays the video on 16:9 phones.'
});
add('TikTok', 'In-feed ad', 1080, 1920, {
    safe: [160, 140, 600, 60],
    zones: [
        top(160, 'Tabs, LIVE & search'), bottom(600, 'Ad caption, CTA button & nav bar'),
        box(940, 700, 140, 620, 'Action buttons'),
        left(60, 'Edge', 'caution', 160, 1320), right(60, 'Edge', 'caution', 160, 700)
    ],
    mockup: ['reel', 'tiktokAd'],
    notes: 'Ads add a "Sponsored" label and a CTA button above the caption: keep the bottom 600 px clear.'
});
add('TikTok', 'Profile picture', 720, 720, { zones: [circleCrop()], notes: 'Displayed as a circle (200' + TIMES + '200 min).' });

// ---------------------------------------------------------------- Pinterest
add('Pinterest', 'Standard pin 2:3', 1000, 1500, {
    zones: pinZones(1500), mockup: ['pin'],
    notes: 'Recommended pin ratio. On hover, Pinterest overlays Save, the link and share buttons.'
});
add('Pinterest', 'Square pin', 1000, 1000, { zones: pinZones(1000), mockup: ['pin'], notes: '1:1.' });
add('Pinterest', 'Long pin', 1000, 2100, { zones: pinZones(2100), mockup: ['pin'], notes: '1:2.1 is the maximum; taller pins get cut off in the feed.' });
add('Pinterest', 'Video pin', 1080, 1920, {
    safe: [270, 195, 440, 65],
    zones: [top(270, 'Close, profile & menu'), bottom(440, 'Title, profile & Save'), right(195, 'Actions', 'danger', 270, 1480), left(65, 'Edge', 'caution', 270, 1480)],
    mockup: ['reel', 'pinterest'],
    notes: '9:16. Pinterest guidance: keep 270 px top, 440 px bottom, 65 px left and 195 px right clear.'
});
add('Pinterest', 'Profile cover', 1600, 900, {
    zones: [oval(680, 780, 240, 240, 'Profile photo ' + APPROX, 'caution')],
    mockup: ['avatar', { r: [680, 780, 240] }],
    notes: '16:9. The profile photo overlaps the bottom centre.'
});

// ---------------------------------------------------------------- Snapchat
add('Snapchat', 'Story / Spotlight / Snap Ad', 1080, 1920, {
    safe: [150, 60, 340, 60],
    zones: [top(150, 'Profile & menu'), bottom(340, 'Chat bar, CTA & attachment ' + APPROX), left(60, 'Edge', 'caution', 150, 1580), right(60, 'Edge', 'caution', 150, 1580)],
    mockup: ['story', { bars: 1, name: 'yourbrand', time: '2h', right: ['moreV'], pill: 'Send a chat', icons: ['heart'] }],
    notes: 'Snap Ads: brand name & headline at the top, CTA / swipe-up at the bottom.'
});

// ---------------------------------------------------------------- Threads
add('Threads', 'Post portrait 4:5', 1080, 1350, { notes: 'Shown up to 4:5 in the feed.' });
add('Threads', 'Post square', 1080, 1080, { notes: '1:1.' });

// ---------------------------------------------------------------- WhatsApp
add('WhatsApp', 'Status', 1080, 1920, {
    safe: [220, 40, 260, 40],
    zones: [top(220, 'Progress, name & time'), bottom(260, 'Reply & reactions'), left(40, '', 'caution', 220, 1660), right(40, '', 'caution', 220, 1660)],
    mockup: ['story', { back: true, name: 'Your Brand', time: 'Today, 10:42', right: ['moreV'], reply: true }],
    notes: 'Statuses disappear after 24 h. Video up to 60 s.'
});
add('WhatsApp', 'Profile picture', 640, 640, { zones: [circleCrop()], notes: 'Displayed as a circle.' });

// ---------------------------------------------------------------- Telegram
add('Telegram', 'Story', 1080, 1920, {
    safe: [220, 40, 260, 40],
    zones: [top(220, 'Progress, name & menu'), bottom(260, 'Reply & reactions')],
    mockup: ['story', { name: 'Your Channel', time: '2h', right: ['close', 'moreV'], pill: 'Reply privately\u2026', icons: ['heart'] }],
    notes: 'Channel & user stories, 9:16.'
});

// ---------------------------------------------------------------- Twitch
add('Twitch', 'Profile banner', 1200, 480, { notes: 'Max 10 MB. Displayed behind the channel header; keep content centred.' });
add('Twitch', 'Offline screen', 1920, 1080, { notes: 'Shown in the player when you are offline.' });
add('Twitch', 'Panel', 320, 160, { notes: 'Panels are 320 px wide, height up to 600 px.' });

// ---------------------------------------------------------------- Discord
add('Discord', 'Server banner', 960, 540, {
    zones: [top(190, 'Server name header ' + APPROX, 'caution')],
    notes: '16:9 (boost level 2). The server name header overlays the top of the banner.'
});
add('Discord', 'Profile banner', 680, 240, {
    zones: [oval(22, 148, 184, 184, 'Avatar ' + APPROX)],
    mockup: ['avatar', { r: [22, 148, 184] }],
    notes: 'Nitro profile banner. The avatar overlaps the bottom-left.'
});

// ---------------------------------------------------------------- Bluesky
add('Bluesky', 'Banner', 1500, 500, {
    zones: [oval(30, 340, 260, 260, 'Avatar ' + APPROX)],
    mockup: ['avatar', { r: [30, 340, 260] }],
    notes: '3:1. The avatar overlaps the bottom-left.'
});

// ---------------------------------------------------------------- Generic
add('Multi-platform', 'Universal vertical 9:16', 1080, 1920, {
    safe: [270, 195, 670, 65],
    zones: [
        top(270, 'Top UI (all platforms)'), bottom(670, 'Captions, CTAs & nav bars'),
        box(885, 760, 195, 490, 'Action rails'),
        left(65, 'Edge', 'caution', 270, 1250), right(65, 'Edge', 'caution', 270, 760),
        crop([3, 4], 'Instagram grid 3:4', false), crop([4, 5], 'Feed preview 4:5', false)
    ],
    notes: 'Combined safe zone of Instagram Reels & Stories, Facebook, TikTok, YouTube Shorts, Snapchat and Pinterest: design once, post everywhere.'
});
add('Multi-platform', 'Video 16:9 (title / action safe)', 1920, 1080, {
    safe: [54, 96, 54, 96],
    zones: [cropRect(67, 38, 1786, 1004, 'Action safe 93%', false, 'tr')],
    notes: 'Broadcast-style safe areas: green = title safe (90%), blue = action safe (93%).'
});

// =============================================================================
// 2. ICONS (24 x 24 grid, SVG-like path syntax: M L H V C Z, absolute only)
// =============================================================================

function pc(cx, cy, r) {
    var k = 0.5523 * r;
    return 'M' + cx + ' ' + (cy - r) +
        ' C' + (cx + k) + ' ' + (cy - r) + ' ' + (cx + r) + ' ' + (cy - k) + ' ' + (cx + r) + ' ' + cy +
        ' C' + (cx + r) + ' ' + (cy + k) + ' ' + (cx + k) + ' ' + (cy + r) + ' ' + cx + ' ' + (cy + r) +
        ' C' + (cx - k) + ' ' + (cy + r) + ' ' + (cx - r) + ' ' + (cy + k) + ' ' + (cx - r) + ' ' + cy +
        ' C' + (cx - r) + ' ' + (cy - k) + ' ' + (cx - k) + ' ' + (cy - r) + ' ' + cx + ' ' + (cy - r) + ' Z ';
}
function prr(x, y, w, h, r) {
    var k = 0.5523 * r;
    return 'M' + (x + r) + ' ' + y + ' H' + (x + w - r) +
        ' C' + (x + w - r + k) + ' ' + y + ' ' + (x + w) + ' ' + (y + r - k) + ' ' + (x + w) + ' ' + (y + r) +
        ' V' + (y + h - r) +
        ' C' + (x + w) + ' ' + (y + h - r + k) + ' ' + (x + w - r + k) + ' ' + (y + h) + ' ' + (x + w - r) + ' ' + (y + h) +
        ' H' + (x + r) +
        ' C' + (x + r - k) + ' ' + (y + h) + ' ' + x + ' ' + (y + h - r + k) + ' ' + x + ' ' + (y + h - r) +
        ' V' + (y + r) +
        ' C' + x + ' ' + (y + r - k) + ' ' + (x + r - k) + ' ' + y + ' ' + (x + r) + ' ' + y + ' Z ';
}

var ICONS = {
    heart: 'M12 21.35 L10.55 20.03 C5.4 15.36 2 12.28 2 8.5 C2 5.42 4.42 3 7.5 3 C9.24 3 10.91 3.81 12 5.09 C13.09 3.81 14.76 3 16.5 3 C19.58 3 22 5.42 22 8.5 C22 12.28 18.6 15.36 13.45 20.04 Z',
    comment: 'M12 2 C16.97 2 21 6.03 21 11 C21 15.97 16.97 20 12 20 C10.42 20 8.87 19.58 7.5 18.79 L3 21 L3.54 14.08 C3.18 13.09 3 12.05 3 11 C3 6.03 7.03 2 12 2 Z',
    bubble: 'M4 3.5 H20 C21.1 3.5 22 4.4 22 5.5 V15 C22 16.1 21.1 17 20 17 H10 L5 21 V17 H4 C2.9 17 2 16.1 2 15 V5.5 C2 4.4 2.9 3.5 4 3.5 Z',
    send: 'M11.7 20.3 L22 3 L2 3 L9.2 10.1 Z M22 3 L9.2 10.1',
    share: 'M13 4.5 L22 12 L13 19.5 V15.2 C7.6 15.2 4.6 16.6 2 20.5 C3 14.2 6.6 9.6 13 9 Z',
    bookmark: 'M6 3 H18 V21 L12 16.5 L6 21 Z',
    more: pc(5, 12, 1.9) + pc(12, 12, 1.9) + pc(19, 12, 1.9),
    moreV: pc(12, 5, 1.9) + pc(12, 12, 1.9) + pc(12, 19, 1.9),
    close: 'M5 5 L19 19 M19 5 L5 19',
    plus: 'M12 4 V20 M4 12 H20',
    search: pc(10.5, 10.5, 6.5) + 'M15.4 15.4 L21 21',
    camera: prr(2.5, 5.5, 19, 15, 3.5) + pc(12, 13, 4),
    music: 'M9 17.5 V5 L20 3 V15.5 ' + pc(6.5, 17.5, 2.5) + pc(17.5, 15.5, 2.5),
    thumb: 'M2 10 H6 V21 H2 Z M6 10 L10.5 3 C11.9 3 13 4.1 12.7 5.5 L12 9.5 H19.5 C20.9 9.5 21.9 10.8 21.5 12.2 L19.6 19.4 C19.3 20.4 18.4 21 17.4 21 H6 Z',
    repost: 'M4 10 V8 C4 6.9 4.9 6 6 6 H19 M16 3 L19 6 L16 9 M20 14 V16 C20 17.1 19.1 18 18 18 H5 M8 21 L5 18 L8 15',
    home: 'M3 10.5 L12 3 L21 10.5 V21 H15 V14.5 H9 V21 H3 Z',
    user: pc(12, 8, 4.2) + 'M3.5 21 C3.5 16.5 7.3 14 12 14 C16.7 14 20.5 16.5 20.5 21 Z',
    users: pc(9, 8, 3.6) + 'M2 20 C2 16 5 14 9 14 C13 14 16 16 16 20 Z ' + pc(17, 9, 2.8) + 'M17 14 C20 14 22 16 22 19',
    chevronDown: 'M6 9 L12 15 L18 9',
    chevronUp: 'M6 15 L12 9 L18 15',
    chevronLeft: 'M15 5 L8 12 L15 19',
    play: 'M7 4.5 L19.5 12 L7 19.5 Z',
    clock: pc(12, 12, 9) + 'M12 7 V12 L15.5 14.5',
    queue: 'M3 6 H17 M3 11 H17 M3 16 H10 M14 14 L21 17.5 L14 21 Z',
    fullscreen: 'M3 9 V3 H9 M15 3 H21 V9 M21 15 V21 H15 M9 21 H3 V15',
    gear: pc(12, 12, 3.2) + pc(12, 12, 8.5),
    remix: pc(12, 12, 8.5) + 'M8 12 H16 M12 8 V16'
};

// Parses the mini path syntax into [{closed, pts:[{a:[x,y], l:[x,y], r:[x,y]}]}]
// (l = incoming handle, r = outgoing handle)
function parsePath(d) {
    var toks = d.match(/[MLHVCZ]|-?\d*\.?\d+(?:e-?\d+)?/g), subs = [], cur = null, i = 0, cmd = '', x = 0, y = 0;
    function num() { return parseFloat(toks[i++]); }
    function push(px, py, l) { cur.pts.push({ a: [px, py], l: l || [px, py], r: [px, py] }); }
    while (i < toks.length) {
        if (/^[MLHVCZ]$/.test(toks[i])) cmd = toks[i++];
        if (cmd === 'Z') {
            var p = cur.pts, f0 = p[0], last = p[p.length - 1];
            if (p.length > 1 && Math.abs(last.a[0] - f0.a[0]) < 1e-6 && Math.abs(last.a[1] - f0.a[1]) < 1e-6) {
                f0.l = last.l; p.pop();
            }
            cur.closed = true; cmd = ''; continue;
        }
        if (cmd === 'M') { x = num(); y = num(); cur = { closed: false, pts: [] }; subs.push(cur); push(x, y); cmd = 'L'; }
        else if (cmd === 'L') { x = num(); y = num(); push(x, y); }
        else if (cmd === 'H') { x = num(); push(x, y); }
        else if (cmd === 'V') { y = num(); push(x, y); }
        else if (cmd === 'C') {
            var c1 = [num(), num()], c2 = [num(), num()];
            x = num(); y = num();
            cur.pts[cur.pts.length - 1].r = c1;
            push(x, y, c2);
        } else { i++; }
    }
    return subs;
}

// =============================================================================
// 3. PHOTOSHOP HELPERS (Action Manager)
// =============================================================================

function c2t(s) { return charIDToTypeID(s); }
function s2t(s) { return stringIDToTypeID(s); }

function rgbDesc(c) {
    var d = new ActionDescriptor();
    d.putDouble(c2t('Rd  '), c[0]); d.putDouble(c2t('Grn '), c[1]); d.putDouble(c2t('Bl  '), c[2]);
    return d;
}
function solid(c) { var s = new SolidColor(); s.rgb.red = c[0]; s.rgb.green = c[1]; s.rgb.blue = c[2]; return s; }

function boundsDesc(x, y, w, h, radius) {
    var s = new ActionDescriptor(), px = c2t('#Pxl');
    s.putInteger(s2t('unitValueQuadVersion'), 1);
    s.putUnitDouble(c2t('Top '), px, y); s.putUnitDouble(c2t('Left'), px, x);
    s.putUnitDouble(c2t('Btom'), px, y + h); s.putUnitDouble(c2t('Rght'), px, x + w);
    if (radius) {
        s.putUnitDouble(s2t('topRight'), px, radius); s.putUnitDouble(s2t('topLeft'), px, radius);
        s.putUnitDouble(s2t('bottomLeft'), px, radius); s.putUnitDouble(s2t('bottomRight'), px, radius);
    }
    return s;
}

// New shape layer (rectangle / ellipse) above the active layer
function makeShape(kind, x, y, w, h, color, radius) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), fill = new ActionDescriptor();
    fill.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    layer.putObject(c2t('Type'), s2t('solidColorLayer'), fill);
    layer.putObject(c2t('Shp '), c2t(kind === 'ellipse' ? 'Elps' : 'Rctn'), boundsDesc(x, y, w, h, kind === 'ellipse' ? 0 : radius));
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

// Cuts a rectangle / ellipse out of the active shape layer
function subtractShape(kind, x, y, w, h) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putEnumerated(c2t('Path'), c2t('Ordn'), c2t('Trgt')); desc.putReference(c2t('null'), ref);
    desc.putObject(c2t('T   '), c2t(kind === 'ellipse' ? 'Elps' : 'Rctn'), boundsDesc(x, y, w, h));
    executeAction(c2t('SbtF'), desc, DialogModes.NO);
}

// Linear gradient shape layer (vertical), opacities in % at the top and bottom
function makeGradient(x, y, w, h, color, opTop, opBottom) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), gl = new ActionDescriptor(), grad = new ActionDescriptor();
    gl.putUnitDouble(c2t('Angl'), c2t('#Ang'), 90);
    gl.putEnumerated(c2t('Type'), c2t('GrdT'), c2t('Lnr '));
    gl.putBoolean(c2t('Algn'), true);
    grad.putString(c2t('Nm  '), 'Scrim');
    grad.putEnumerated(c2t('GrdF'), c2t('GrdF'), c2t('CstS'));
    grad.putDouble(c2t('Intr'), 4096);
    var clrs = new ActionList(), trns = new ActionList(), ops = [opBottom, opTop];
    for (var i = 0; i < 2; i++) {
        var st = new ActionDescriptor();
        st.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
        st.putEnumerated(c2t('Type'), c2t('Clry'), c2t('UsrS'));
        st.putInteger(c2t('Lctn'), i * 4096); st.putInteger(c2t('Mdpn'), 50);
        clrs.putObject(c2t('Clrt'), st);
        var t = new ActionDescriptor();
        t.putUnitDouble(c2t('Opct'), c2t('#Prc'), ops[i]);
        t.putInteger(c2t('Lctn'), i * 4096); t.putInteger(c2t('Mdpn'), 50);
        trns.putObject(c2t('TrnS'), t);
    }
    grad.putList(c2t('Clrs'), clrs); grad.putList(c2t('Trns'), trns);
    gl.putObject(c2t('Grad'), c2t('Grdn'), grad);
    layer.putObject(c2t('Type'), s2t('gradientLayer'), gl);
    layer.putObject(c2t('Shp '), c2t('Rctn'), boundsDesc(x, y, w, h));
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

// Shape layer from path data (see parsePath), positioned with a transform function
function makePathShape(subs, tf, color) {
    var doc = app.activeDocument, infos = [];
    for (var i = 0; i < subs.length; i++) {
        var pts = [];
        for (var j = 0; j < subs[i].pts.length; j++) {
            var p = subs[i].pts[j], ppi = new PathPointInfo();
            ppi.kind = PointKind.CORNERPOINT;
            ppi.anchor = tf(p.a); ppi.leftDirection = tf(p.r); ppi.rightDirection = tf(p.l);
            pts.push(ppi);
        }
        var spi = new SubPathInfo();
        // XOR keeps inner outlines (camera lens, clock hands) visible once stroked
        spi.closed = subs[i].closed; spi.operation = i ? ShapeOperation.SHAPEXOR : ShapeOperation.SHAPEADD;
        spi.entireSubPath = pts;
        infos.push(spi);
    }
    var path = doc.pathItems.add('ssz-tmp', infos);
    path.select();
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putClass(s2t('contentLayer')); desc.putReference(c2t('null'), ref);
    var layer = new ActionDescriptor(), fill = new ActionDescriptor();
    fill.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    layer.putObject(c2t('Type'), s2t('solidColorLayer'), fill);
    desc.putObject(c2t('Usng'), s2t('contentLayer'), layer);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
    path.remove();
}

// Stroke on the active shape layer. o: {fill, dash:[on,off], align:'inside'|'center'|'outside', round}
function setStroke(width, color, o) {
    o = o || {};
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putEnumerated(s2t('contentLayer'), c2t('Ordn'), c2t('Trgt')); desc.putReference(c2t('null'), ref);
    var style = new ActionDescriptor(), st = new ActionDescriptor();
    st.putInteger(s2t('strokeStyleVersion'), 2);
    st.putBoolean(s2t('strokeEnabled'), true);
    st.putBoolean(s2t('fillEnabled'), !!o.fill);
    st.putUnitDouble(s2t('strokeStyleLineWidth'), c2t('#Pxl'), width);
    var align = { inside: 'strokeStyleAlignInside', center: 'strokeStyleAlignCenter', outside: 'strokeStyleAlignOutside' }[o.align || 'inside'];
    st.putEnumerated(s2t('strokeStyleLineAlignment'), s2t('strokeStyleLineAlignment'), s2t(align));
    st.putEnumerated(s2t('strokeStyleLineCapType'), s2t('strokeStyleLineCapType'), s2t(o.round ? 'strokeStyleRoundCap' : 'strokeStyleButtCap'));
    st.putEnumerated(s2t('strokeStyleLineJoinType'), s2t('strokeStyleLineJoinType'), s2t(o.round ? 'strokeStyleRoundJoin' : 'strokeStyleMiterJoin'));
    var dash = new ActionList();
    if (o.dash) { dash.putUnitDouble(c2t('#Nne'), o.dash[0]); dash.putUnitDouble(c2t('#Nne'), o.dash[1]); }
    st.putList(s2t('strokeStyleLineDashSet'), dash);
    var content = new ActionDescriptor();
    content.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(color));
    st.putObject(s2t('strokeStyleContent'), s2t('solidColorLayer'), content);
    style.putObject(s2t('strokeStyle'), s2t('strokeStyle'), st);
    desc.putObject(c2t('T   '), s2t('shapeStyle'), style);
    executeAction(c2t('setd'), desc, DialogModes.NO);
}

// Layer style on the active layer / group. o: {shadow: [opacity, size], stroke: [size, colour]}
function setEffects(o) {
    var desc = new ActionDescriptor(), ref = new ActionReference();
    ref.putProperty(c2t('Prpr'), c2t('Lefx'));
    ref.putEnumerated(c2t('Lyr '), c2t('Ordn'), c2t('Trgt'));
    desc.putReference(c2t('null'), ref);
    var fx = new ActionDescriptor();
    fx.putUnitDouble(c2t('Scl '), c2t('#Prc'), 100);
    if (o.shadow) {
        var ds = new ActionDescriptor();
        ds.putBoolean(c2t('enab'), true);
        ds.putEnumerated(c2t('Md  '), c2t('BlnM'), c2t('Mltp'));
        ds.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc([0, 0, 0]));
        ds.putUnitDouble(c2t('Opct'), c2t('#Prc'), o.shadow[0]);
        ds.putBoolean(c2t('uglg'), false);
        ds.putUnitDouble(c2t('lagl'), c2t('#Ang'), 90);
        ds.putUnitDouble(c2t('Dstn'), c2t('#Pxl'), Math.max(1, Math.round(o.shadow[1] / 4)));
        ds.putUnitDouble(c2t('Ckmt'), c2t('#Pxl'), 0);
        ds.putUnitDouble(c2t('blur'), c2t('#Pxl'), o.shadow[1]);
        fx.putObject(c2t('DrSh'), c2t('DrSh'), ds);
    }
    if (o.stroke) {
        var st = new ActionDescriptor();
        st.putBoolean(c2t('enab'), true);
        st.putEnumerated(c2t('Styl'), c2t('FStl'), c2t('OutF'));
        st.putEnumerated(c2t('PntT'), c2t('FrFl'), c2t('SClr'));
        st.putEnumerated(c2t('Md  '), c2t('BlnM'), c2t('Nrml'));
        st.putUnitDouble(c2t('Opct'), c2t('#Prc'), 100);
        st.putUnitDouble(c2t('Sz  '), c2t('#Pxl'), o.stroke[0]);
        st.putObject(c2t('Clr '), c2t('RGBC'), rgbDesc(o.stroke[1]));
        fx.putObject(c2t('FrFX'), c2t('FrFX'), st);
    }
    desc.putObject(c2t('T   '), c2t('Lefx'), fx);
    executeAction(c2t('setd'), desc, DialogModes.NO);
}

function addGuide(pos, horizontal) {
    var desc = new ActionDescriptor(), g = new ActionDescriptor();
    g.putUnitDouble(c2t('Pstn'), c2t('#Pxl'), pos);
    g.putEnumerated(c2t('Ornt'), c2t('Ornt'), c2t(horizontal ? 'Hrzn' : 'Vrtc'));
    desc.putObject(c2t('Nw  '), c2t('Gd  '), g);
    executeAction(c2t('Mk  '), desc, DialogModes.NO);
}

function layerBounds(l) {
    var b = l.bounds;
    return [b[0].as('px'), b[1].as('px'), b[2].as('px'), b[3].as('px')];
}

var FONT = null;
function fonts() {
    if (FONT) return FONT;
    var pick = function (names) {
        for (var i = 0; i < names.length; i++) {
            try { app.fonts.getByName(names[i]); return names[i]; } catch (e) {}
        }
        return names[names.length - 1];
    };
    FONT = {
        reg: pick(['HelveticaNeue', 'ArialMT']),
        bold: pick(['HelveticaNeue-Bold', 'Arial-BoldMT'])
    };
    return FONT;
}

// =============================================================================
// 4. DRAWER - creates layers at the top of a group, in call order
// =============================================================================

var WHITE = [255, 255, 255], BLACK = [0, 0, 0], GREY = [170, 170, 170];

function Drawer(doc, group) { this.doc = doc; this.group = group; }

Drawer.prototype._prep = function () {
    // A new layer is created above the active one; with an empty group active, it goes inside.
    this.doc.activeLayer = this.group.layers.length ? this.group.layers[0] : this.group;
};
Drawer.prototype._done = function (name, o) {
    var l = this.doc.activeLayer;
    l.name = name;
    if (o && o.o !== undefined && o.o < 100) l.opacity = o.o;
    return l;
};
// o: {c: fill colour, o: opacity, r: radius, stroke: width, sc: stroke colour, fill: keep fill with stroke, dash, align}
Drawer.prototype.rect = function (name, x, y, w, h, o) {
    o = o || {};
    this._prep();
    makeShape('rect', x, y, w, h, o.c || WHITE, o.r);
    if (o.stroke) setStroke(o.stroke, o.sc || o.c || WHITE, { fill: o.fill, dash: o.dash, align: o.align });
    return this._done(name, o);
};
Drawer.prototype.ellipse = function (name, x, y, w, h, o) {
    o = o || {};
    this._prep();
    makeShape('ellipse', x, y, w, h, o.c || WHITE);
    if (o.stroke) setStroke(o.stroke, o.sc || o.c || WHITE, { fill: o.fill, dash: o.dash, align: o.align });
    return this._done(name, o);
};
Drawer.prototype.circle = function (name, cx, cy, d, o) { return this.ellipse(name, cx - d / 2, cy - d / 2, d, d, o); };
// Filled rectangle minus an ellipse
Drawer.prototype.outside = function (name, x, y, w, h, hole, o) {
    o = o || {};
    this._prep();
    makeShape('rect', x, y, w, h, o.c || WHITE);
    subtractShape('ellipse', hole[0], hole[1], hole[2], hole[3]);
    return this._done(name, o);
};
Drawer.prototype.scrim = function (name, x, y, w, h, opTop, opBottom) {
    this._prep();
    try {
        makeGradient(x, y, w, h, BLACK, opTop, opBottom);
    } catch (e) {
        makeShape('rect', x, y, w, h, BLACK);
        this.doc.activeLayer.opacity = Math.max(opTop, opBottom) / 2;
    }
    return this._done(name);
};
// o: {c, o, fill: true for a filled icon, sw: stroke width in 24-grid units, flipY}
Drawer.prototype.icon = function (name, id, cx, cy, size, o) {
    o = o || {};
    var sc = size / 24, fy = o.flipY ? -1 : 1;
    var tf = function (p) { return [cx + (p[0] - 12) * sc, cy + (p[1] - 12) * sc * fy]; };
    this._prep();
    makePathShape(parsePath(ICONS[id]), tf, o.c || WHITE);
    if (!o.fill) setStroke((o.sw || 1.9) * sc, o.c || WHITE, { align: 'center', round: true });
    return this._done(name, o);
};
// o: {c, o, bold, align: 'left'|'center'|'right'}
Drawer.prototype.text = function (name, str, x, y, size, o) {
    o = o || {};
    var l = this.group.artLayers.add();
    l.kind = LayerKind.TEXT;
    var t = l.textItem;
    t.contents = str;
    t.font = o.bold ? fonts().bold : fonts().reg;
    t.size = new UnitValue(size, 'px');
    t.color = solid(o.c || WHITE);
    t.justification = o.align === 'center' ? Justification.CENTER : (o.align === 'right' ? Justification.RIGHT : Justification.LEFT);
    t.position = [new UnitValue(x, 'px'), new UnitValue(y, 'px')];
    l.name = name;
    if (o.o !== undefined && o.o < 100) l.opacity = o.o;
    return l;
};
Drawer.prototype.right = function (layer) { return layerBounds(layer)[2]; };

// =============================================================================
// 5. UI MOCKUPS
// =============================================================================

var MOCKUPS = {};

// ---- Stories (Instagram, Facebook, Snapchat, WhatsApp, Telegram)
MOCKUPS.story = function (D, f, c) {
    var W = f.w, H = f.h, i;
    D.scrim('Top shade', 0, 0, W, 330, 45, 0);
    D.scrim('Bottom shade', 0, H - 320, W, 320, 0, 50);
    var n = c.bars || 3, mx = 16, gap = 8, bw = (W - 2 * mx - (n - 1) * gap) / n;
    for (i = 0; i < n; i++) D.rect('Progress ' + (i + 1), mx + i * (bw + gap), 28, bw, 6, { r: 3, o: i === 0 ? 100 : 45 });
    var ax = 32, ay = 64;
    if (c.back) { D.icon('Back', 'chevronLeft', 52, ay + 44, 56); ax = 92; }
    D.ellipse('Avatar', ax, ay, 88, 88, { c: GREY });
    D.icon('Avatar icon', 'user', ax + 44, ay + 46, 54, { fill: true, c: [235, 235, 235] });
    var nm = D.text('Name', c.name, ax + 108, ay + 56, 32, { bold: true });
    if (c.time) D.text('Time', c.time, D.right(nm) + 16, ay + 56, 30, { o: 75 });
    for (i = 0; i < (c.right || []).length; i++) D.icon(c.right[i], c.right[i], W - 56 - i * 96, ay + 44, 56);
    if (c.reply) {
        D.icon('Reply arrow', 'chevronUp', W / 2, H - 140, 52);
        D.text('Reply', 'Reply', W / 2, H - 70, 32, { bold: true, align: 'center' });
        return;
    }
    var icons = c.icons || [], pillW = W - 64 - icons.length * 110;
    D.rect('Message field', 32, H - 150, pillW, 96, { r: 48, stroke: 3, o: 90 });
    D.text('Message placeholder', c.pill, 76, H - 90, 32, { o: 90 });
    for (i = 0; i < icons.length; i++) D.icon(icons[i], icons[i], 32 + pillW + 62 + i * 110, H - 102, 60);
};

// ---- Vertical video feeds (Reels, TikTok, Shorts, LinkedIn, Pinterest)
var REEL_CFG = {
    instagram: {
        header: 'Reels', chevron: true, topIcons: ['camera'], railTop: 1110, rail: [
            { icon: 'heart', text: '12.4K' }, { icon: 'comment', text: '348' }, { icon: 'send', text: '1,024' }, { icon: 'more' }, { audio: true }
        ],
        name: 'yourbrand', avatar: true, follow: 'Follow', bottomY: 1640,
        caption: 'Your caption goes here\u2026 more', audio: 'yourbrand' + DOT + 'Original audio'
    },
    facebook: {
        header: 'Reels', topIcons: ['user', 'search'], railTop: 1110, rail: [
            { icon: 'thumb', text: '2.1K' }, { icon: 'bubble', text: '348' }, { icon: 'share', text: 'Share' }, { icon: 'more' }
        ],
        name: 'Your Page', avatar: true, follow: 'Follow', bottomY: 1640,
        caption: 'Your caption goes here\u2026 See more', audio: 'Your Page' + DOT + 'Original audio'
    },
    tiktok: {
        tabs: true, filled: true, railTop: 880, rail: [
            { tiktokAvatar: true }, { icon: 'heart', text: '1.2M' }, { icon: 'bubble', text: '8,421' },
            { icon: 'bookmark', text: '52K' }, { icon: 'share', text: '12K' }, { disc: true }
        ],
        name: '@yourbrand', bottomY: 1590, caption: 'Your caption goes here #fyp',
        audio: 'original sound - yourbrand', nav: true
    },
    tiktokAd: {
        tabs: true, filled: true, railTop: 760, rail: [
            { tiktokAvatar: true }, { icon: 'heart', text: '24K' }, { icon: 'bubble', text: '311' }, { icon: 'share', text: 'Share' }, { disc: true }
        ],
        name: 'yourbrand', sponsored: true, bottomY: 1440, caption: 'Your ad caption goes here',
        audio: 'Promoted music', cta: 'Learn more', nav: true
    },
    shorts: {
        topIcons: ['moreV', 'search'], iconBg: true, railTop: 980, railStep: 145, rail: [
            { icon: 'thumb', text: '12K' }, { icon: 'thumb', text: 'Dislike', flip: true }, { icon: 'bubble', text: '348' },
            { icon: 'share', text: 'Share' }, { icon: 'remix', text: 'Remix' }, { audio: true }
        ],
        name: '@yourbrand', avatar: true, follow: 'Subscribe', followFilled: true, bottomY: 1600,
        caption: 'Your Short title goes here', audio: 'Original sound' + DOT + 'yourbrand'
    },
    linkedin: {
        topIcons: ['more'], back: true, railTop: 1000, rail: [
            { icon: 'thumb', text: 'Like' }, { icon: 'bubble', text: 'Comment' }, { icon: 'repost', text: 'Repost' }, { icon: 'send', text: 'Send' }
        ],
        name: 'Your Name', avatar: true, follow: '+ Follow', bottomY: 1560, caption: 'Your post text goes here\u2026 more'
    },
    pinterest: {
        topIcons: ['more'], back: true, railTop: 1040, rail: [
            { icon: 'heart', text: '1.4K' }, { icon: 'bubble', text: '36' }, { icon: 'share' }
        ],
        name: 'yourbrand', avatar: true, follow: 'Follow', bottomY: 1580, caption: 'Your pin title goes here', save: true
    }
};

MOCKUPS.reel = function (D, f, key) {
    var c = REEL_CFG[key], W = f.w, H = f.h, i, y, l, b;
    D.scrim('Top shade', 0, 0, W, 360, 45, 0);
    D.scrim('Bottom shade', 0, H - 760, W, 760, 0, 60);

    // header
    if (c.tabs) {
        D.rect('LIVE badge', 36, 74, 104, 52, { r: 10, stroke: 3 });
        D.text('LIVE', 'LIVE', 88, 111, 26, { bold: true, align: 'center' });
        D.text('Tab Following', 'Following', W / 2 - 24, 112, 34, { bold: true, align: 'right', o: 65 });
        l = D.text('Tab For You', 'For You', W / 2 + 24, 112, 36, { bold: true });
        b = layerBounds(l);
        D.rect('Tab underline', (b[0] + b[2]) / 2 - 30, 128, 60, 6, { r: 3 });
        D.icon('Search', 'search', W - 70, 100, 60);
    } else if (c.header) {
        l = D.text('Header', c.header, 40, 140, 50, { bold: true });
        if (c.chevron) D.icon('Header chevron', 'chevronDown', D.right(l) + 34, 124, 40);
    } else if (c.back) {
        D.icon('Back', 'chevronLeft', 60, 120, 60);
    }
    for (i = 0; i < (c.topIcons || []).length; i++) D.icon(c.topIcons[i], c.topIcons[i], W - 70 - i * 100, 120, 60);

    // action rail
    var rx = W - 70, step = c.railStep || 150;
    y = c.railTop;
    for (i = 0; i < c.rail.length; i++) {
        var it = c.rail[i];
        if (it.tiktokAvatar) {
            D.circle('Profile ring', rx, y, 100, { c: WHITE });
            D.circle('Profile', rx, y, 92, { c: GREY });
            D.icon('Profile icon', 'user', rx, y + 2, 56, { fill: true, c: [235, 235, 235] });
            D.circle('Follow badge', rx, y + 50, 40, { c: [254, 44, 85] });
            D.icon('Follow plus', 'plus', rx, y + 50, 26, { sw: 3 });
            y += 170; continue;
        }
        if (it.disc) {
            D.circle('Sound disc', rx, y, 92, { c: [35, 35, 35] });
            D.circle('Sound disc cover', rx, y, 48, { c: GREY });
            y += step; continue;
        }
        if (it.audio) {
            D.rect('Audio cover', rx - 34, y - 34, 68, 68, { r: 12, c: GREY, stroke: 4, sc: WHITE, fill: true });
            y += step; continue;
        }
        if (c.iconBg) D.circle('Icon background', rx, y, 96, { c: BLACK, o: 30 });
        D.icon(it.text || it.icon, it.icon, rx, y, c.iconBg ? 56 : 68, { fill: !!c.filled, flipY: it.flip });
        if (it.text) D.text('Count', it.text, rx, y + (c.iconBg ? 84 : 78), 26, { bold: true, align: 'center' });
        y += step;
    }

    // author, caption, audio
    var by = c.bottomY, tx = 32;
    if (c.sponsored) D.text('Sponsored', 'Sponsored', 32, by - 56, 26, { o: 75 });
    if (c.avatar) {
        D.ellipse('Author avatar', 32, by - 52, 72, 72, { c: GREY });
        D.icon('Author avatar icon', 'user', 68, by - 15, 44, { fill: true, c: [235, 235, 235] });
        tx = 120;
    }
    l = D.text('Author', c.name, tx, by, 32, { bold: true });
    if (c.follow) {
        var fx = D.right(l) + 22, fw = c.follow.length * 17 + 44;
        if (c.followFilled) {
            D.rect('Follow button', fx, by - 42, fw, 58, { r: 29 });
            D.text('Follow label', c.follow, fx + fw / 2, by - 3, 26, { bold: true, align: 'center', c: BLACK });
        } else {
            D.rect('Follow button', fx, by - 42, fw, 58, { r: 14, stroke: 3 });
            D.text('Follow label', c.follow, fx + fw / 2, by - 3, 26, { bold: true, align: 'center' });
        }
    }
    D.text('Caption', c.caption, 32, by + 76, 30, { o: 95 });
    if (c.audio) {
        D.icon('Audio icon', 'music', 50, by + 128, 34);
        D.text('Audio', c.audio, 80, by + 140, 28, { o: 90 });
    }
    if (c.cta) {
        D.rect('CTA button', 32, by + 180, W - 64 - 150, 80, { r: 8, c: [254, 44, 85] });
        D.text('CTA label', c.cta, 32 + (W - 64 - 150) / 2, by + 232, 32, { bold: true, align: 'center' });
    }
    if (c.save) {
        D.rect('Save button', W - 200, by + 60, 168, 80, { r: 40, c: [230, 0, 35] });
        D.text('Save label', 'Save', W - 116, by + 112, 32, { bold: true, align: 'center' });
    }

    // bottom navigation (TikTok overlays it on 16:9 phones)
    if (c.nav) {
        var ny = H - 150, labels = ['Home', 'Friends', '', 'Inbox', 'Profile'], ic = ['home', 'users', '', 'bubble', 'user'];
        D.rect('Nav bar', 0, ny, W, 150, { c: BLACK, o: 92 });
        for (i = 0; i < 5; i++) {
            var cx = W * (i + 0.5) / 5;
            if (i === 2) {
                D.rect('Create button', cx - 52, ny + 40, 104, 68, { r: 16 });
                D.icon('Create plus', 'plus', cx, ny + 74, 40, { c: BLACK, sw: 2.6 });
                continue;
            }
            D.icon(labels[i], ic[i], cx, ny + 58, 52, { fill: i === 0, o: i === 0 ? 100 : 80 });
            D.text(labels[i] + ' label', labels[i], cx, ny + 118, 22, { align: 'center', bold: i === 0, o: i === 0 ? 100 : 80 });
        }
    }
};

// ---- Instagram feed overlays
MOCKUPS.igFeed = function (D, f) {
    D.rect('Carousel counter', f.w - 124, 24, 100, 56, { r: 28, c: [20, 20, 20], o: 70 });
    D.text('Carousel counter label', '1/10', f.w - 74, 62, 28, { bold: true, align: 'center' });
    if (f.h >= 1000) {
        D.circle('Tags button', 56, f.h - 56, 64, { c: [20, 20, 20], o: 70 });
        D.icon('Tags icon', 'user', 56, f.h - 55, 34, { fill: true });
    }
};

// ---- Profile photo over a banner / cover. c.r = [x, y, diameter], c.square for logos
MOCKUPS.avatar = function (D, f, c) {
    var x = c.r[0], y = c.r[1], d = c.r[2], ring = Math.max(4, Math.round(d * 0.03));
    if (c.square) {
        D.rect('Logo border', x, y, d, d, { r: d * 0.08 });
        D.rect('Logo', x + ring, y + ring, d - 2 * ring, d - 2 * ring, { r: d * 0.06, c: GREY });
    } else {
        D.ellipse('Profile photo border', x, y, d, d);
        D.ellipse('Profile photo', x + ring, y + ring, d - 2 * ring, d - 2 * ring, { c: GREY });
    }
    D.icon('Profile icon', 'user', x + d / 2, y + d / 2 + d * 0.02, d * 0.6, { fill: true, c: [235, 235, 235] });
};

// ---- X link card
MOCKUPS.xCard = function (D, f) {
    D.rect('Domain label', 24, 552, 300, 52, { r: 8, c: BLACK, o: 70 });
    D.text('Domain', 'yourbrand.com', 40, 588, 28);
};

// ---- LinkedIn document viewer
MOCKUPS.liDoc = function (D, f) {
    D.rect('Title bar', 0, 0, f.w, 80, { c: BLACK, o: 70 });
    D.text('Title', 'Your document title', 32, 52, 28, { bold: true });
    D.rect('Page counter', 24, f.h - 66, 140, 48, { r: 24, c: BLACK, o: 70 });
    D.text('Page counter label', '1 / 10', 94, f.h - 32, 26, { bold: true, align: 'center' });
    D.icon('Fullscreen', 'fullscreen', f.w - 50, f.h - 42, 36);
};

// ---- YouTube
var YT_RED = [255, 0, 51];
MOCKUPS.ytThumb = function (D, f) {
    D.rect('Progress track', 0, f.h - 12, f.w, 12, { c: [120, 120, 120], o: 70 });
    D.rect('Progress watched', 0, f.h - 12, f.w * 0.42, 12, { c: YT_RED });
    D.rect('Duration badge', 1110, 628, 142, 64, { r: 10, c: BLACK, o: 80 });
    D.text('Duration', '12:34', 1181, 673, 34, { bold: true, align: 'center' });
    D.rect('Watch later button', 1180, 24, 76, 76, { r: 10, c: BLACK, o: 80 });
    D.icon('Watch later icon', 'clock', 1218, 62, 44);
    D.rect('Add to queue button', 1180, 112, 76, 76, { r: 10, c: BLACK, o: 80 });
    D.icon('Add to queue icon', 'queue', 1218, 150, 44);
};
MOCKUPS.ytPlayer = function (D, f) {
    var W = f.w, H = f.h;
    D.scrim('Top shade', 0, 0, W, 180, 55, 0);
    D.scrim('Bottom shade', 0, H - 260, W, 260, 0, 65);
    D.circle('Channel avatar', 66, 62, 64, { c: GREY });
    D.text('Video title', 'Your video title', 116, 74, 34);
    D.rect('Progress track', 24, H - 98, W - 48, 6, { c: WHITE, o: 35 });
    D.rect('Progress played', 24, H - 98, 640, 6, { c: YT_RED });
    D.circle('Scrubber', 664, H - 95, 24, { c: YT_RED });
    D.icon('Play', 'play', 66, H - 42, 48, { fill: true });
    D.text('Time', '4:12 / 12:34', 120, H - 32, 28);
    D.icon('Settings', 'gear', W - 160, H - 42, 44);
    D.icon('Fullscreen', 'fullscreen', W - 66, H - 42, 44);
};
MOCKUPS.ytEnd = function (D, f) {
    var tiles = [[190, 330], [1010, 330]];
    for (var i = 0; i < 2; i++) {
        D.rect('Video element ' + (i + 1), tiles[i][0], tiles[i][1], 720, 405, { r: 12, c: [25, 25, 25], o: 90 });
        D.icon('Video element play', 'play', tiles[i][0] + 360, tiles[i][1] + 202, 90, { fill: true, o: 80 });
    }
    D.circle('Subscribe element', 960, 895, 250, { c: GREY });
    D.icon('Subscribe icon', 'user', 960, 900, 150, { fill: true, c: [235, 235, 235] });
};

// ---- Pinterest pin hover state
MOCKUPS.pin = function (D, f) {
    var H = f.h;
    D.rect('Save button', 690, 50, 260, 150, { r: 75, c: [230, 0, 35] });
    D.text('Save label', 'Save', 820, 148, 64, { bold: true, align: 'center' });
    D.rect('Link button', 50, H - 186, 520, 136, { r: 68, o: 92 });
    D.text('Link label', 'yourbrand.com', 310, H - 100, 52, { bold: true, align: 'center', c: BLACK });
    D.circle('Share button', 738, H - 118, 136, { o: 92 });
    D.icon('Share icon', 'share', 738, H - 118, 64, { fill: true, c: BLACK });
    D.circle('More button', 882, H - 118, 136, { o: 92 });
    D.icon('More icon', 'more', 882, H - 118, 64, { fill: true, c: BLACK });
};

// =============================================================================
// 6. ZONE GEOMETRY (shared by the builder and the dialog preview)
// =============================================================================

var KIND = {
    danger: { fill: [255, 40, 70], text: [190, 0, 40], group: 'Covered by UI', op: 1 },
    caution: { fill: [255, 160, 0], text: [175, 95, 0], group: 'May be covered', op: 0.9 },
    crop: { fill: [0, 140, 255], text: [0, 95, 200], group: 'Cropped in some views', op: 0.8 },
    safe: { fill: [0, 200, 110], text: [0, 135, 75] }
};

function round(v) { return Math.round(v); }

// Returns { fills: [...], frames: [...], guides: {h: [], v: []} }
function resolve(f) {
    var W = f.w, H = f.h, fills = [], frames = [], gh = [], gv = [], i, z, r;
    function frameGuides(x, y, w, h) { gv.push(x, x + w); gh.push(y, y + h); }
    for (i = 0; i < f.zones.length; i++) {
        z = f.zones[i];
        if (z.t === 'band') {
            var a = z.from || 0, b, lab = z.label ? z.label + DOT + z.size + ' px' : '';
            if (z.side === 'top') { b = z.to || W; r = [a, 0, b - a, z.size]; }
            else if (z.side === 'bottom') { b = z.to || W; r = [a, H - z.size, b - a, z.size]; }
            else if (z.side === 'left') { b = z.to || H; r = [0, a, z.size, b - a]; }
            else { b = z.to || H; r = [W - z.size, a, z.size, b - a]; }
            fills.push({ shape: 'rect', r: r, k: z.k, label: lab, side: z.side });
        } else if (z.t === 'rect' || z.t === 'ellipse') {
            fills.push({ shape: z.t, r: z.r, k: z.k, label: z.label });
        } else if (z.t === 'outside') {
            var d = Math.min(W, H);
            r = z.r || [(W - d) / 2, (H - d) / 2, d, d];
            fills.push({ shape: 'outside', r: r, k: z.k, label: z.label });
            frames.push({ shape: 'ellipse', r: r, k: z.k });
            frameGuides(r[0], r[1], r[2], r[3]);
        } else if (z.t === 'crop') {
            if (z.r) r = z.r;
            else {
                var ar = z.ratio[0] / z.ratio[1], cw = W, ch = H;
                if (W / H > ar) cw = round(H * ar); else ch = round(W / ar);
                r = [round((W - cw) / 2), round((H - ch) / 2), cw, ch];
            }
            if (z.tint) {
                if (r[1] > 0) fills.push({ shape: 'rect', r: [0, 0, W, r[1]], k: 'crop' });
                if (r[1] + r[3] < H) fills.push({ shape: 'rect', r: [0, r[1] + r[3], W, H - r[1] - r[3]], k: 'crop' });
                if (r[0] > 0) fills.push({ shape: 'rect', r: [0, r[1], r[0], r[3]], k: 'crop' });
                if (r[0] + r[2] < W) fills.push({ shape: 'rect', r: [r[0] + r[2], r[1], W - r[0] - r[2], r[3]], k: 'crop' });
            }
            frames.push({ shape: 'rect', r: r, k: 'crop', label: z.label, lp: z.lp || 'tl' });
            frameGuides(r[0], r[1], r[2], r[3]);
        }
    }
    if (f.safe) {
        var s = f.safe;
        r = [s[3], s[0], W - s[1] - s[3], H - s[0] - s[2]];
        frames.push({ shape: 'rect', r: r, k: 'safe', label: 'Safe area' + DOT + 'text & logos', lp: f.safeLabel || 'bl' });
        frameGuides(r[0], r[1], r[2], r[3]);
    }
    return { fills: fills, frames: frames, guides: { h: gh, v: gv } };
}

// =============================================================================
// 7. DOCUMENT BUILDER
// =============================================================================

var BACKGROUNDS = [
    { name: 'White', c: [255, 255, 255] },
    { name: 'Light grey', c: [237, 237, 237] },
    { name: 'Dark grey', c: [40, 40, 40] },
    { name: 'Black', c: [0, 0, 0] },
    { name: 'Transparent', c: null }
];

function clip(r, W, H) {
    var x0 = Math.max(0, r[0]), y0 = Math.max(0, r[1]), x1 = Math.min(W, r[0] + r[2]), y1 = Math.min(H, r[1] + r[3]);
    return [x0, y0, Math.max(0, x1 - x0), Math.max(0, y1 - y0)];
}

// Centred, auto-fitting label inside a rectangle (rotated for tall narrow areas)
function fitLabel(D, str, r, size, color) {
    if (!str || r[2] < 8 || r[3] < 8) return;
    var rotate = r[3] > r[2] * 1.5;
    var cx = r[0] + r[2] / 2, cy = r[1] + r[3] / 2;
    var l = D.text(str, str, cx, cy + size * 0.35, size, { bold: true, align: 'center', c: color });
    if (rotate) l.rotate(-90, AnchorPosition.MIDDLECENTER);
    var b = layerBounds(l), bw = b[2] - b[0], bh = b[3] - b[1];
    var k = Math.min(1, (r[2] * 0.9) / bw, (r[3] * 0.75) / bh);
    if (k < 1) {
        if (size * k < 9) { l.remove(); return; }
        l.textItem.size = new UnitValue(size * k, 'px');
        b = layerBounds(l);
    }
    l.translate(cx - (b[0] + b[2]) / 2, cy - (b[1] + b[3]) / 2);
}

// Label next to a small zone (buttons, badges, avatars, action rails) so it never covers the mockup:
// below or above it, or beside it for tall rails.
function sideLabel(D, str, r, W, H, size, color) {
    var v = clip(r, W, H);
    if (!str || v[2] < 1 || v[3] < 1) return;
    var tall = v[3] > v[2] * 1.5, gap = size * 0.5;
    var l = D.text(str, str, v[0] + v[2] / 2, v[1] + v[3] / 2, size, { bold: true, align: 'center', c: color });
    if (tall) l.rotate(-90, AnchorPosition.MIDDLECENTER);
    var b = layerBounds(l), bw = b[2] - b[0], bh = b[3] - b[1], tx, ty;
    if (tall) {
        tx = v[0] + v[2] / 2 > W / 2 ? v[0] - gap - bw / 2 : v[0] + v[2] + gap + bw / 2;
        ty = v[1] + v[3] / 2;
    } else {
        tx = v[0] + v[2] / 2;
        ty = v[1] + v[3] + gap + bh <= H - gap ? v[1] + v[3] + gap + bh / 2 : v[1] - gap - bh / 2;
    }
    tx = Math.max(bw / 2 + gap, Math.min(W - bw / 2 - gap, tx));
    ty = Math.max(bh / 2 + gap, Math.min(H - bh / 2 - gap, ty));
    l.translate(tx - (b[0] + b[2]) / 2, ty - (b[1] + b[3]) / 2);
}

function cornerLabel(D, str, r, lp, size, color) {
    if (!str) return;
    var pad = Math.round(size * 0.6), right = lp === 'tr' || lp === 'br', bottom = lp === 'bl' || lp === 'br';
    var x = right ? r[0] + r[2] - pad : r[0] + pad;
    var y = bottom ? r[1] + r[3] - pad : r[1] + pad + size * 0.8;
    D.text(str, str, x, y, size, { bold: true, align: right ? 'right' : 'left', c: color });
}

function build(doc, f, opts) {
    var W = f.w, H = f.h, i;
    var S = Math.max(12, Math.min(40, Math.round(Math.sqrt(W * H) / 48)));

    // background
    var bg = BACKGROUNDS[opts.bg] || BACKGROUNDS[0];
    if (bg.c && opts.bg > 0) {
        doc.activeLayer = doc.backgroundLayer;
        doc.selection.selectAll(); doc.selection.fill(solid(bg.c)); doc.selection.deselect();
    }

    // artwork group (a transparent canvas starts with an empty layer: replace it)
    var initial = doc.layers.length === 1 && !doc.layers[0].isBackgroundLayer ? doc.layers[0] : null;
    var art = doc.layerSets.add();
    art.name = 'ARTWORK';
    var design = art.artLayers.add();
    design.name = 'Your design';
    if (initial) initial.remove();

    // Everything that is not artwork lives in one group, so a single click hides it before export:
    //   Labels / Frames / UI mockup / Covered by UI / May be covered / Cropped in some views
    var geo = resolve(f);
    var wantMockup = opts.mockup && f.mockup && MOCKUPS[f.mockup[0]];
    var wantZones = (opts.zones || opts.labels) && (geo.fills.length || geo.frames.length);
    if (wantMockup || wantZones) {
        var root = doc.layerSets.add();
        root.name = '\u26A0 SAFE ZONES' + DOT + 'hide before export';
        var groups = {}, order = ['crop', 'caution', 'danger'], locked = [];
        for (i = 0; i < order.length; i++) {
            var g = root.layerSets.add();
            g.name = KIND[order[i]].group;
            g.blendMode = BlendMode.NORMAL; // isolate: overlapping zones keep one even tint
            g.opacity = Math.round(opts.opacity * KIND[order[i]].op);
            groups[order[i]] = g;
            locked.push(g);
        }

        // UI mockup, above the tinted areas so it stays crisp
        if (wantMockup) {
            var mock = root.layerSets.add();
            mock.name = 'UI mockup' + DOT + f.platform + ' ' + f.name;
            MOCKUPS[f.mockup[0]](new Drawer(doc, mock), f, f.mockup[1] || {});
            doc.activeLayer = mock;
            try { setEffects({ shadow: [45, Math.max(4, Math.round(S / 2))] }); } catch (e) {}
        }

        var gFrames = root.layerSets.add(); gFrames.name = 'Frames';
        var gLabels = root.layerSets.add(); gLabels.name = 'Labels';
        locked.push(gFrames, gLabels);
        var DL = new Drawer(doc, gLabels), DF = new Drawer(doc, gFrames);

        for (i = 0; i < geo.fills.length; i++) {
            var z = geo.fills[i], r = z.r, kd = KIND[z.k], name = z.label || kd.group;
            if (opts.zones) {
                var D = new Drawer(doc, groups[z.k]);
                if (z.shape === 'outside') D.outside(name, 0, 0, W, H, r, { c: kd.fill });
                else if (z.shape === 'ellipse') D.ellipse(name, r[0], r[1], r[2], r[3], { c: kd.fill });
                else D.rect(name, r[0], r[1], r[2], r[3], { c: kd.fill });
            }
            if (opts.labels && z.label) {
                var lr = clip(r, W, H), lh = Math.min(lr[3], S * 1.6);
                // band labels hug the safe-area side, where the platform UI rarely sits
                if (z.side === 'top') lr = [lr[0], lr[1] + lr[3] - lh, lr[2], lh];
                else if (z.side === 'bottom') lr = [lr[0], lr[1], lr[2], lh];
                else if (z.shape === 'outside') lr = r[1] > H * 0.08 ? [0, 0, W, r[1]] : [0, 0, W * 0.18, H * 0.18];
                else if (lr[2] * lr[3] < W * H * 0.15) { sideLabel(DL, z.label, r, W, H, Math.round(S * 0.85), kd.text); continue; }
                fitLabel(DL, z.label, lr, S, kd.text);
            }
        }
        var lw = Math.max(2, Math.round(S / 10));
        for (i = 0; i < geo.frames.length; i++) {
            var fr = geo.frames[i], kf = KIND[fr.k], fname = fr.label || (fr.k === 'safe' ? 'Safe area' : 'Frame');
            if (opts.zones) {
                if (fr.shape === 'ellipse') DF.ellipse(fname, fr.r[0], fr.r[1], fr.r[2], fr.r[3], { c: kf.fill, stroke: lw, dash: [4, 3] });
                else DF.rect(fname, fr.r[0], fr.r[1], fr.r[2], fr.r[3], { c: kf.fill, stroke: lw, dash: fr.k === 'safe' ? null : [4, 3] });
            }
            if (opts.labels) cornerLabel(DL, fr.label, fr.r, fr.lp, Math.round(S * 0.8), kf.text);
        }
        if (gLabels.layers.length) {
            doc.activeLayer = gLabels;
            try { setEffects({ stroke: [Math.max(2, Math.round(S / 9)), WHITE] }); } catch (e) {}
        }
        // drop empty groups, lock the guides (the mockup stays editable)
        for (i = 0; i < locked.length; i++) {
            if (!locked[i].layers.length) locked[i].remove();
            else locked[i].allLocked = true;
        }
    }

    // guides
    if (opts.guides || opts.center) {
        var gh = opts.guides ? geo.guides.h.slice(0) : [], gv = opts.guides ? geo.guides.v.slice(0) : [];
        if (opts.center) { gh.push(H / 2); gv.push(W / 2); }
        var seen = {};
        for (i = 0; i < gh.length; i++) if (gh[i] > 0 && gh[i] < H && !seen['h' + gh[i]]) { seen['h' + gh[i]] = 1; addGuide(gh[i], true); }
        for (i = 0; i < gv.length; i++) if (gv[i] > 0 && gv[i] < W && !seen['v' + gv[i]]) { seen['v' + gv[i]] = 1; addGuide(gv[i], false); }
    }

    try {
        doc.info.title = f.platform + ' ' + f.name;
        doc.info.caption = f.notes || '';
    } catch (e) {}
    doc.activeLayer = design;
}

var JOB = null;
$.global.__socialSafeZonesJob = function () {
    try { build(app.activeDocument, JOB.f, JOB.opts); } catch (e) { JOB.err = e; }
};

function createDocument(f, opts) {
    var name = f.platform.replace(/[()]/g, '') + ' - ' + f.name.replace(/[\/:]/g, '-') + ' (' + f.w + 'x' + f.h + ')';
    var fill = BACKGROUNDS[opts.bg] && !BACKGROUNDS[opts.bg].c ? DocumentFill.TRANSPARENT : DocumentFill.WHITE;
    var doc;
    try {
        doc = app.documents.add(new UnitValue(f.w, 'px'), new UnitValue(f.h, 'px'), 72, name, NewDocumentMode.RGB, fill, 1, BitsPerChannelType.EIGHT, 'sRGB IEC61966-2.1');
    } catch (e) {
        doc = app.documents.add(new UnitValue(f.w, 'px'), new UnitValue(f.h, 'px'), 72, name, NewDocumentMode.RGB, fill);
    }
    JOB = { f: f, opts: opts, err: null };
    doc.suspendHistory('Social Safe Zones', '__socialSafeZonesJob()');
    if (JOB.err) throw JOB.err;
    try { app.runMenuItem(c2t('FtOn')); } catch (e) {}
    return doc;
}

// =============================================================================
// 8. DIALOG
// =============================================================================

function gcd(a, b) { return b ? gcd(b, a % b) : a; }
function ratioText(w, h) {
    var g = gcd(w, h), a = w / g, b = h / g;
    return (a <= 32 && b <= 32) ? a + ':' + b : (w / h).toFixed(2) + ':1';
}
function sizeText(f) { return f.w + ' ' + TIMES + ' ' + f.h; }

function platforms() {
    var list = [], seen = {};
    for (var i = 0; i < LIB.length; i++) if (!seen[LIB[i].platform]) { seen[LIB[i].platform] = 1; list.push(LIB[i].platform); }
    return list;
}
function countFor(p) { var n = 0; for (var i = 0; i < LIB.length; i++) if (LIB[i].platform === p) n++; return n; }
function inArray(a, v) { for (var i = 0; i < a.length; i++) if (a[i] === v) return true; return false; }

function loadPrefs() {
    var p = { platform: 'Instagram', ids: ['instagram-story-1080x1920'], opts: { zones: true, labels: true, guides: true, center: false, mockup: true, opacity: 35, bg: 0 } };
    try {
        if (PREFS_FILE.exists) {
            PREFS_FILE.encoding = 'UTF-8';
            PREFS_FILE.open('r');
            var saved = eval(PREFS_FILE.read());
            PREFS_FILE.close();
            if (saved && saved.opts) p = saved;
        }
    } catch (e) {}
    return p;
}
function savePrefs(p) {
    try {
        PREFS_FILE.encoding = 'UTF-8';
        PREFS_FILE.open('w'); PREFS_FILE.write(p.toSource()); PREFS_FILE.close();
    } catch (e) {}
}

function showDialog(prefs) {
    var ALL = 'All formats', plats = platforms(), items = [ALL], i;
    for (i = 0; i < plats.length; i++) items.push(plats[i] + '  (' + countFor(plats[i]) + ')');

    var w = new Window('dialog', 'Social Safe Zones ' + VERSION);
    w.orientation = 'column'; w.alignChildren = ['fill', 'top']; w.spacing = 10; w.margins = 16;

    var main = w.add('group');
    main.orientation = 'row'; main.alignChildren = ['fill', 'fill']; main.spacing = 10;

    var p1 = main.add('panel', undefined, 'Platform');
    var platList = p1.add('listbox', undefined, items);
    platList.preferredSize = [190, 400];

    var p2 = main.add('panel', undefined, 'Format');
    p2.alignChildren = ['fill', 'top'];
    var sg = p2.add('group');
    sg.add('statictext', undefined, 'Search:');
    var search = sg.add('edittext', undefined, '');
    search.preferredSize.width = 250;
    var list = p2.add('listbox', undefined, [], { multiselect: true });
    list.preferredSize = [330, 340];
    p2.add('statictext', undefined, 'Cmd/Ctrl-click to select several \u2014 double-click to create.');

    var p3 = main.add('panel', undefined, 'Preview');
    p3.alignChildren = ['fill', 'top'];
    var canvas = p3.add('group');
    canvas.preferredSize = [260, 220];
    var title = p3.add('statictext', undefined, '');
    title.preferredSize = [260, 20];
    try { title.graphics.font = ScriptUI.newFont(title.graphics.font.name, 'BOLD', 13); } catch (e) {}
    var dims = p3.add('statictext', undefined, '');
    dims.preferredSize = [260, 18];
    var notes = p3.add('statictext', undefined, '', { multiline: true });
    notes.preferredSize = [260, 120];

    var op = w.add('panel', undefined, 'Options');
    op.orientation = 'row'; op.alignChildren = ['left', 'center']; op.spacing = 14;
    var cZones = op.add('checkbox', undefined, 'Safe-zone overlays');
    var cLabels = op.add('checkbox', undefined, 'Labels');
    var cGuides = op.add('checkbox', undefined, 'Guides');
    var cCenter = op.add('checkbox', undefined, 'Centre guides');
    var cMock = op.add('checkbox', undefined, 'UI mockup');
    var og = op.add('group');
    og.add('statictext', undefined, 'Opacity');
    var slider = og.add('slider', undefined, prefs.opts.opacity, 10, 80);
    slider.preferredSize.width = 100;
    var opTxt = og.add('statictext', undefined, prefs.opts.opacity + '%');
    opTxt.preferredSize.width = 36;
    var bgg = op.add('group');
    bgg.add('statictext', undefined, 'Background');
    var bgNames = [];
    for (i = 0; i < BACKGROUNDS.length; i++) bgNames.push(BACKGROUNDS[i].name);
    var bgList = bgg.add('dropdownlist', undefined, bgNames);
    bgList.selection = prefs.opts.bg || 0;

    cZones.value = prefs.opts.zones; cLabels.value = prefs.opts.labels; cGuides.value = prefs.opts.guides;
    cCenter.value = prefs.opts.center; cMock.value = prefs.opts.mockup;
    slider.onChanging = function () { opTxt.text = Math.round(slider.value) + '%'; };

    var btns = w.add('group');
    btns.alignment = ['fill', 'bottom'];
    var legend = btns.add('statictext', undefined, 'Red = covered by UI    Orange = may be covered    Blue = cropped    Green = safe area');
    legend.alignment = ['left', 'center'];
    var spacer = btns.add('group'); spacer.alignment = ['fill', 'center'];
    var cancel = btns.add('button', undefined, 'Cancel', { name: 'cancel' });
    var ok = btns.add('button', undefined, 'Create', { name: 'ok' });
    ok.preferredSize.width = 150;

    var current = null, selectedIds = prefs.ids || [], ready = false, filling = false;

    function selection() {
        var s = list.selection, out = [];
        if (!s) return out;
        if (!(s instanceof Array)) s = [s];
        for (var j = 0; j < s.length; j++) out.push(s[j].fmt);
        return out;
    }

    function refreshList() {
        var plat = platList.selection ? platList.selection.index : 0;
        var q = search.text.toLowerCase().replace(/^\s+|\s+$/g, ''), words = q ? q.split(/\s+/) : [];
        var showPlatform = plat === 0 || words.length > 0;
        filling = true; // rebuilding the list must not reset the remembered selection
        list.removeAll();
        var pre = [];
        for (var j = 0; j < LIB.length; j++) {
            var f = LIB[j];
            if (!words.length && plat > 0 && f.platform !== plats[plat - 1]) continue;
            var hay = (f.platform + ' ' + f.name + ' ' + f.w + 'x' + f.h + ' ' + (f.notes || '')).toLowerCase(), ok2 = true;
            for (var k = 0; k < words.length; k++) if (hay.indexOf(words[k]) < 0) { ok2 = false; break; }
            if (!ok2) continue;
            var it = list.add('item', (showPlatform ? f.platform + DOT : '') + f.name + '   \u2014   ' + sizeText(f));
            it.fmt = f;
            if (inArray(selectedIds, f.id)) pre.push(it.index);
        }
        // item.selected = true is ignored by multi-select lists on macOS; set the selection instead
        if (pre.length) {
            try { list.selection = pre; } catch (e) { list.selection = pre[0]; }
        }
        filling = false;
        updateSelection();
    }

    function updateSelection() {
        var sel = selection();
        current = sel.length ? sel[sel.length - 1] : null;
        ok.text = sel.length > 1 ? 'Create ' + sel.length + ' documents' : 'Create';
        ok.enabled = sel.length > 0;
        if (current) {
            title.text = current.platform + DOT + current.name;
            dims.text = sizeText(current) + ' px' + DOT + ratioText(current.w, current.h);
            notes.text = (current.notes || '').replace(/\s*&\s*/g, ' and '); // ScriptUI treats & as a mnemonic
        } else {
            title.text = ''; dims.text = ''; notes.text = 'Select a format.';
        }
        canvas.hide(); canvas.show(); // force redraw
    }

    canvas.onDraw = function () {
        try {
            var g = this.graphics, CW = this.size[0], CH = this.size[1], bgc = [0.17, 0.17, 0.17, 1];
            var fillR = function (x, y, ww, hh, col) { g.newPath(); g.rectPath(x, y, ww, hh); g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, col)); };
            fillR(0, 0, CW, CH, bgc);
            if (!current) return;
            var f = current, sc = Math.min((CW - 16) / f.w, (CH - 16) / f.h), ox = (CW - f.w * sc) / 2, oy = (CH - f.h * sc) / 2;
            var geo = resolve(f), j, z, col;
            var X = function (v) { return ox + v * sc; }, Y = function (v) { return oy + v * sc; };
            fillR(ox, oy, f.w * sc, f.h * sc, [1, 1, 1, 1]);
            var rgba = function (c, a) { return [c[0] / 255, c[1] / 255, c[2] / 255, a]; };
            for (j = 0; j < geo.fills.length; j++) {
                z = geo.fills[j];
                if (z.shape !== 'outside') continue;
                fillR(ox, oy, f.w * sc, f.h * sc, rgba(KIND[z.k].fill, 0.45));
                g.newPath(); g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [1, 1, 1, 1]));
            }
            for (j = 0; j < geo.fills.length; j++) {
                z = geo.fills[j];
                if (z.shape === 'outside') continue;
                col = rgba(KIND[z.k].fill, z.k === 'crop' ? 0.3 : 0.45);
                g.newPath();
                if (z.shape === 'ellipse') g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                else g.rectPath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, col));
            }
            for (j = 0; j < geo.frames.length; j++) {
                z = geo.frames[j];
                g.newPath();
                if (z.shape === 'ellipse') g.ellipsePath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                else g.rectPath(X(z.r[0]), Y(z.r[1]), z.r[2] * sc, z.r[3] * sc);
                g.strokePath(g.newPen(g.PenType.SOLID_COLOR, rgba(KIND[z.k].fill, 1), z.k === 'safe' ? 2 : 1));
            }
            // mask anything drawn outside the canvas
            fillR(0, 0, CW, oy, bgc); fillR(0, oy + f.h * sc, CW, CH, bgc);
            fillR(0, 0, ox, CH, bgc); fillR(ox + f.w * sc, 0, CW, CH, bgc);
        } catch (e) {}
    };

    platList.onChange = function () {
        // keep the remembered selection while the dialog initialises
        if (ready) { selectedIds = []; for (var j = 0, s = selection(); j < s.length; j++) selectedIds.push(s[j].id); }
        refreshList();
    };
    search.onChanging = function () { refreshList(); };
    list.onChange = function () {
        if (filling) return;
        selectedIds = [];
        for (var j = 0, s = selection(); j < s.length; j++) selectedIds.push(s[j].id);
        updateSelection();
    };
    list.onDoubleClick = function () { if (selection().length) w.close(1); };

    var startIndex = 0;
    for (i = 0; i < plats.length; i++) if (plats[i] === prefs.platform) startIndex = i + 1;
    platList.selection = startIndex;
    refreshList();
    ready = true;
    search.active = true;

    if (w.show() !== 1) return null;
    var formats = selection();
    if (!formats.length) return null;
    var opts = {
        zones: cZones.value, labels: cLabels.value, guides: cGuides.value, center: cCenter.value, mockup: cMock.value,
        opacity: Math.round(slider.value), bg: bgList.selection ? bgList.selection.index : 0
    };
    var ids = [];
    for (i = 0; i < formats.length; i++) ids.push(formats[i].id);
    savePrefs({ platform: platList.selection && platList.selection.index > 0 ? plats[platList.selection.index - 1] : ALL, ids: ids, opts: opts });
    return { formats: formats, opts: opts };
}

// =============================================================================
// 9. MAIN
// =============================================================================

function main() {
    var test = $.global.SOCIAL_SAFE_ZONES_TEST, choice;
    if (test) {
        choice = { formats: [], opts: test.opts || loadPrefs().opts };
        for (var t = 0; t < LIB.length; t++) if (!test.ids || inArray(test.ids, LIB[t].id)) choice.formats.push(LIB[t]);
    } else {
        choice = showDialog(loadPrefs());
    }
    if (!choice) return;

    var saved = { ruler: app.preferences.rulerUnits, type: app.preferences.typeUnits, dialogs: app.displayDialogs };
    app.preferences.rulerUnits = Units.PIXELS;
    app.preferences.typeUnits = TypeUnits.PIXELS;
    app.displayDialogs = DialogModes.NO;
    var errors = [];
    try {
        for (var i = 0; i < choice.formats.length; i++) {
            try { createDocument(choice.formats[i], choice.opts); }
            catch (e) { errors.push(choice.formats[i].platform + ' ' + choice.formats[i].name + ': ' + e + (e.line ? ' (line ' + e.line + ')' : '')); }
        }
    } finally {
        app.preferences.rulerUnits = saved.ruler;
        app.preferences.typeUnits = saved.type;
        app.displayDialogs = saved.dialogs;
    }
    if (test) { $.global.SOCIAL_SAFE_ZONES_RESULT = errors; return; }
    if (errors.length) alert('Social Safe Zones \u2014 some formats failed:\n\n' + errors.join('\n'));
}

$.global.SOCIAL_SAFE_ZONES_LIB = LIB;
main();

})();
README.md

Social Safe Zones — Guide d'utilisation

À quoi ça sert ?

Chaque réseau social affiche ses propres boutons par-dessus vos visuels : nom du compte, bouton « J'aime », légende, barre de réponse… Et selon l'endroit où le visuel est affiché (fil, profil, ordinateur, téléphone), une partie de l'image peut être coupée.

Ce script crée en un clic, dans Photoshop, un document au bon format pour le réseau choisi, avec :

  • les zones à éviter, colorées en transparence ;
  • la zone sûre, encadrée en vert, où placer textes et logos ;
  • une simulation de l'interface du réseau (boutons, nom du compte, légende…) pour voir le rendu final ;
  • des repères (lignes guides) aux bons endroits.

Résultat : plus de logo caché derrière un bouton, ni de texte coupé à la publication.


Installation (une seule fois)

  1. Ouvrez le Finder, puis Applications › Adobe Photoshop 2026 › Presets › Scripts (le dossier « Presets » peut s'appeler « Paramètres prédéfinis »).
  2. Copiez-y le fichier Social Safe Zones.jsx.
  3. Redémarrez Photoshop.

Le script apparaît alors dans le menu Fichier › Scripts › Social Safe Zones…

Sans installation, vous pouvez aussi passer par Fichier › Scripts › Parcourir… et sélectionner le fichier.


Utilisation

  1. Dans Photoshop : Fichier › Scripts › Social Safe Zones…
  2. À gauche, choisissez le réseau (Instagram, LinkedIn, TikTok…).
  3. Au centre, choisissez le format (Story, Bannière, Post…).
    • Une recherche est possible, en anglais : tapez par exemple story, banner ou une taille comme 1080x1920.
    • Pour créer plusieurs formats d'un coup, maintenez Cmd (Mac) ou Ctrl (Windows) en cliquant.
  4. À droite, un aperçu montre les zones et les recommandations du format.
  5. Cliquez sur Create. Le document est prêt.

Le script retient votre dernier choix pour la prochaine fois.

Les options (en bas de la fenêtre)

Option Ce que ça fait
Safe-zone overlays Affiche les zones colorées
Labels Écrit le nom de chaque zone
Guides Ajoute les lignes repères
Centre guides Ajoute un repère au centre
UI mockup Ajoute la simulation de l'interface du réseau
Opacity Rend les zones colorées plus ou moins visibles
Background Couleur du fond : blanc, gris, noir ou transparent

Comprendre les couleurs

Couleur Signification
🟥 Rouge Caché par l'interface du réseau. Rien d'important ici.
🟧 Orange Peut être caché (au survol, dans les publicités, sur certains téléphones). À éviter.
🟦 Bleu Coupé dans certains affichages (grille du profil, ordinateur, TV…).
🟩 Cadre vert Zone sûre : textes et logos ici.

Le symbole ≈ indique une mesure approximative : le réseau ne publie pas de chiffre officiel.


Le document créé

Dans le panneau Calques, vous trouverez :

  • ⚠ SAFE ZONES · hide before export : tout ce qui sert de repère (zones, cadres, textes d'aide, simulation de l'interface).
  • ARTWORK : l'emplacement de votre visuel. Il est déjà sélectionné : collez ou glissez votre création, elle se placera au bon endroit.

⚠️ Avant d'exporter

Masquez le groupe « ⚠ SAFE ZONES » en cliquant sur l'œil à gauche de son nom dans le panneau Calques. Sinon, les zones colorées et la simulation apparaîtront dans l'image finale.

Annuler

Un simple Cmd + Z (Mac) ou Ctrl + Z (Windows) supprime tout ce que le script a ajouté.


Formats disponibles (63)

  • Instagram : Story, Reel, posts (portrait, carré, paysage, 3:4), photo de profil, couverture de Story à la une
  • Facebook : posts, Story, Reel, photo de couverture, événement, groupe, photo de profil
  • LinkedIn : bannière de profil personnel, couverture de page entreprise, posts, carrousel PDF, vidéo verticale, événement, article/newsletter, photo de profil, logo
  • YouTube : miniature, bannière de chaîne, vidéo, écran de fin, Shorts, photo de profil, post
  • TikTok : vidéo, publicité, photo de profil
  • X (Twitter) : bannière, posts, carte de lien, photo de profil
  • Pinterest : épingles (standard, carrée, longue, vidéo), couverture de profil
  • Snapchat, WhatsApp, Telegram : Story / Statut
  • Threads, Twitch, Discord, Bluesky : posts, bannières, panneaux
  • Multi-platform : un format vertical universel (Reels, TikTok, Shorts, Stories…) pour créer un seul visuel valable partout

Bon à savoir

  • Les réseaux sociaux modifient régulièrement leur interface. En cas de doute, vérifiez avec l'aperçu de publication du réseau.
  • Les recommandations de chaque format (tailles, durées…) sont rappelées dans l'aperçu du script et dans Fichier › Informations… du document.
  • Format manquant ou mesure à corriger ? Demandez à la personne qui gère le script : tout se modifie facilement dans le fichier.
SafeZones_Promo.mp4
SocialSafeZones_demo.mp4