/*
<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();
})();