diff --git a/android/app/build.gradle b/android/app/build.gradle index ef926979..ab3bf74d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -11,7 +11,7 @@ android { applicationId "com.compassconnections.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 133 + versionCode 134 versionName "1.32.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { diff --git a/backend/firebase/storage.rules b/backend/firebase/storage.rules index 8bbaf917..5e72eb75 100644 --- a/backend/firebase/storage.rules +++ b/backend/firebase/storage.rules @@ -4,7 +4,7 @@ service firebase.storage { match /b/{bucket}/o { match /{allPaths=**} { allow read; - allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024; + allow write: if request.auth != null && request.resource.size <= 20 * 1024 * 1024; } } } diff --git a/common/messages/de.json b/common/messages/de.json index f9952d46..024a713d 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -684,6 +684,7 @@ "onboarding.soft-gate.refine_button": "Profil verfeinern", "onboarding.soft-gate.seo.description": "Starten Sie mit Compass - transparente Verbindungen ohne Algorithmen", "onboarding.soft-gate.seo.title": "Sie sind bereit zu erkunden - Compass", + "onboarding.soft-gate.share_button": "Mein Profil teilen", "onboarding.soft-gate.title": "Sie sind bereit zu erkunden", "onboarding.soft-gate.what_it_means": "Das bedeutet das für Sie:", "onboarding.step1.body1": "Compass entscheidet nicht, wen Sie sehen sollten.", @@ -1556,7 +1557,7 @@ "stats.with_bio": "Mit Bio", "sticky_format_menu.add_embed": "Embed hinzufügen", "sticky_format_menu.add_emoji": "Emoji hinzufügen", - "sticky_format_menu.upload_image": "Bild hochladen", + "sticky_format_menu.upload_image": "Foto oder Video hochladen", "terms.changes.text": "Wir können diese Bedingungen gelegentlich aktualisieren. Die weitere Nutzung von Compass nach Änderungen gilt als Zustimmung zu den neuen Bedingungen.", "terms.changes.title": "6. Änderungen", "terms.contact": "Bei Fragen zu diesen Bedingungen kontaktieren Sie uns bitte unter ", diff --git a/common/messages/fr.json b/common/messages/fr.json index c743785f..7eb41ca8 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -683,6 +683,7 @@ "onboarding.soft-gate.refine_button": "Affiner votre profil", "onboarding.soft-gate.seo.description": "Commencez avec Compass - des connexions transparentes sans algorithmes", "onboarding.soft-gate.seo.title": "Vous êtes prêt à explorer - Compass", + "onboarding.soft-gate.share_button": "Partager mon profil", "onboarding.soft-gate.title": "Vous êtes prêt à explorer", "onboarding.soft-gate.what_it_means": "Voici ce que cela signifie pour vous :", "onboarding.step1.body1": "Compass ne décide pas qui vous devriez voir.", @@ -1555,7 +1556,7 @@ "stats.with_bio": "Complétés", "sticky_format_menu.add_embed": "Ajouter un embed", "sticky_format_menu.add_emoji": "Ajouter un emoji", - "sticky_format_menu.upload_image": "Ajouter une image", + "sticky_format_menu.upload_image": "Ajouter une photo ou vidéo", "terms.changes.text": "Nous pouvons mettre à jour ces Conditions périodiquement. La poursuite de l'utilisation de Compass après les mises à jour vaut acceptation des nouvelles Conditions.", "terms.changes.title": "6. Modifications", "terms.contact": "Pour toute question concernant ces Conditions, veuillez nous contacter à ", diff --git a/common/src/profiles-rendering.ts b/common/src/profiles-rendering.ts index d085fd2c..a826cc13 100644 --- a/common/src/profiles-rendering.ts +++ b/common/src/profiles-rendering.ts @@ -20,22 +20,28 @@ export type DisplayOptions = { showBio: boolean | null | undefined } +/** + * What a profile card shows out of the box: city, age, headline, keywords, bio and the photo. + * Gender stays a toggle people can switch on, it's just off by default. The rest live on the + * profile page — their card renders are commented out in `web/components/profile-grid.tsx`, so + * those flags are inert until they're restored. + */ export const initialDisplayOptions: DisplayOptions = { showPhotos: true, - showAge: undefined, - showGender: undefined, - showLanguages: true, - cardSize: 'medium', + showAge: true, + showCity: true, showHeadline: true, showKeywords: true, - showCity: true, - showOccupation: true, - showSeeking: true, - showInterests: true, - showCauses: false, - showDiet: true, - showSmoking: true, - showDrinks: true, - showMBTI: true, showBio: true, + cardSize: 'medium', + showGender: false, + showLanguages: false, + showOccupation: false, + showSeeking: false, + showInterests: false, + showCauses: false, + showDiet: false, + showSmoking: false, + showDrinks: false, + showMBTI: false, } diff --git a/common/src/util/parse.ts b/common/src/util/parse.ts index 7b7f7165..fabdaf50 100644 --- a/common/src/util/parse.ts +++ b/common/src/util/parse.ts @@ -14,6 +14,7 @@ import {uniq} from 'lodash' import {compareTwoStrings} from 'string-similarity' import Iframe from './tiptap-iframe' +import Video from './tiptap-video' /** get first url in text. like "notion.so " -> "http://notion.so" "notion" -> null */ export function getUrl(text: string) { @@ -49,7 +50,8 @@ export function parseMentions(data: JSONContent): string[] { export const extensions = [ StarterKit, Link, - Image.extend({renderText: () => '[image]'}), + Image.extend({renderText: () => '📷 Photo'}), + Video.extend({renderText: () => '🎥 Video'}), Mention, // user @mention Iframe.extend({ renderText: ({node}) => ('[embed]' + node.attrs.src ? `(${node.attrs.src})` : ''), diff --git a/common/src/util/tiptap-video.ts b/common/src/util/tiptap-video.ts new file mode 100644 index 00000000..fe28d0c2 --- /dev/null +++ b/common/src/util/tiptap-video.ts @@ -0,0 +1,46 @@ +// Modeled on @tiptap/extension-image (there is no official @tiptap/extension-video package). + +import {mergeAttributes, Node} from '@tiptap/core' + +declare module '@tiptap/core' { + interface Commands { + video: { + setVideo: (options: {src: string}) => ReturnType + } + } +} + +const Video = Node.create({ + name: 'video', + + group: 'block', + + draggable: true, + + addAttributes() { + return { + src: { + default: null, + }, + } + }, + + parseHTML() { + return [{tag: 'video[src]'}] + }, + + renderHTML({HTMLAttributes}) { + return ['video', mergeAttributes(HTMLAttributes, {controls: true})] + }, + + addCommands() { + return { + setVideo: + (options: {src: string}) => + ({commands}) => + commands.insertContent({type: this.name, attrs: options}), + } + }, +}) + +export default Video diff --git a/media-creator/README.md b/media-creator/README.md index 00973672..68a96c92 100644 --- a/media-creator/README.md +++ b/media-creator/README.md @@ -110,6 +110,31 @@ canvas. Formats are defined in `src/theme.ts` (`FORMATS`) and registered in `src > Don't post the 9:16 story as a feed post — Instagram crops it to ~4:5. Use `IntroPost` for the feed. +### Profile scroll b-roll (`ProfileScrollStory`) + +A 6-second silent scroll of one profile, made to sit **under the closing sentences of a talking-head +clip** — not to stand on its own. No audio (the voice on the track is the audio), no captions, no logo +animation. The profile URL is on screen from the first frame to the last, because most people meet a +clip like this as a screenshot or a repost, with no link sticker attached. + +The motion is one steady scroll with a single hold on the "good news / bad news" paragraph, which is +the passage such a voiceover is usually about. A little velocity-proportional blur covers the fast +middle stretch; at 30fps a hard-edged scroll strobes. + +```bash +node scripts/capture-profile.mjs https://www.compassmeet.com/mhg1 --out profile-mhg1 +npm run render:scroll # -> out/compass-profile-scroll-story.mp4 9:16 (1080×1920) +``` + +`--out ` is what keeps this from overwriting `public/profile/`, which the profile-tour video is +built from. The scene reads three cards — header, details, bio — and stacks them in page order; the +`SHOTS` table at the top of `src/scenes/ProfileScroll.tsx` carries their dimensions and the two +offsets inside the bio card that the hold is aimed at, so a bio of a different length needs those +numbers re-measured. + +> Long renders here can trip the 30s `delayRender` on `BrandFonts` (registered for the OG card, but +> bundled into every composition). Hence `--timeout=120000` in the script. + ## Stills ### Social preview card (`OgCard`) diff --git a/media-creator/package.json b/media-creator/package.json index 008a0c6a..31d8264e 100644 --- a/media-creator/package.json +++ b/media-creator/package.json @@ -17,6 +17,7 @@ "render:alert:dark": "remotion render SearchAlertDark out/compass-search-alert-dark.mp4 --crf 30", "render:carousel": "node scripts/render-carousel.mjs", "render:post": "remotion render IntroPost out/compass-intro-post.mp4", + "render:scroll": "remotion render ProfileScrollStory out/compass-profile-scroll-story.mp4 --timeout=120000", "render:search": "remotion render SearchDemoLight out/compass-search-demo-light.mp4 --crf 30", "render:search:dark": "remotion render SearchDemoDark out/compass-search-demo-dark.mp4 --crf 30", "render:story": "remotion render IntroStory out/compass-intro-story.mp4", diff --git a/media-creator/scripts/capture-profile.mjs b/media-creator/scripts/capture-profile.mjs index 432d3dc6..2bdeda08 100644 --- a/media-creator/scripts/capture-profile.mjs +++ b/media-creator/scripts/capture-profile.mjs @@ -31,8 +31,13 @@ const {chromium} = pw const HERE = dirname(fileURLToPath(import.meta.url)) const args = process.argv.slice(2) const LOGIN = args.includes('--login') -const URL = args.find((a) => !a.startsWith('--')) ?? 'http://localhost:3000/Martin' -const OUT_DIR = join(HERE, '..', 'public', 'profile') +// --out writes elsewhere under public/, so capturing a second profile doesn't +// overwrite the artwork the profile-tour video is built from. +const outAt = args.indexOf('--out') +const OUT_NAME = outAt === -1 ? 'profile' : args[outAt + 1] +const URL = + args.find((a, i) => !a.startsWith('--') && i !== outAt + 1) ?? 'http://localhost:3000/Martin' +const OUT_DIR = join(HERE, '..', 'public', OUT_NAME) const PROFILE_DIR = join(HERE, '..', '.auth-profile') // Card title -> output file. Titles come from the ProfileCard headings in diff --git a/media-creator/src/Root.tsx b/media-creator/src/Root.tsx index 763b9e07..dc69d498 100644 --- a/media-creator/src/Root.tsx +++ b/media-creator/src/Root.tsx @@ -2,6 +2,7 @@ import {Composition, Still} from 'remotion' import {FORMATS} from './theme' import {Intro, INTRO_DURATION} from './scenes/Intro' import {OgCard} from './scenes/OgCard' +import {ProfileScroll, PROFILE_SCROLL_DURATION} from './scenes/ProfileScroll' import {ProfileTour, PROFILE_TOUR_DURATION} from './scenes/ProfileTour' import {SearchDemo, SearchDemoProps, calculateSearchDemoMetadata} from './scenes/SearchDemo' import {SearchAlert, SearchAlertProps, calculateSearchAlertMetadata} from './scenes/SearchAlert' @@ -46,6 +47,17 @@ export const RemotionRoot: React.FC = () => { width={FORMATS.post.width} height={FORMATS.post.height} /> + {/* Six-second silent b-roll of one profile, to lay under the closing sentences of a + talking-head story. Story format only — it is never a standalone post. + node scripts/capture-profile.mjs --out profile-mhg1 && npm run render:scroll */} + {/* Home-page hero clip. Not a social format: its canvas is the capture viewport, and both size and duration come from public/search/manifest.json via calculateMetadata, so a re-capture with a different --query needs no change here. diff --git a/media-creator/src/scenes/ProfileScroll.tsx b/media-creator/src/scenes/ProfileScroll.tsx new file mode 100644 index 00000000..ede5f619 --- /dev/null +++ b/media-creator/src/scenes/ProfileScroll.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import {AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, useVideoConfig} from 'remotion' +import {FORMATS, colors, fonts} from '../theme' + +// A six-second silent b-roll scroll of a single profile, made to sit *under* the +// closing sentences of a talking-head clip rather than to stand on its own: +// +// - no audio, no captions, no logo animation — the voice on the track is the audio +// - the profile URL is on screen from frame 0 to the last frame, because most +// people meet this clip as a screenshot or a repost, without a link sticker +// - a beat on the header, a readable pass down the details, then a slightly +// slower pass down the whole of the bio, ending on its last line +// +// Artwork is real screenshots of the live page, captured by scripts/capture-profile.mjs: +// node scripts/capture-profile.mjs https://www.compassmeet.com/mhg1 --out profile-mhg1 +// Re-run that after any profile-UI change; update SHOTS below if a card's height moves. + +const DIR = 'profile-mhg1' +const PROFILE_URL = 'compassmeet.com/mhg1' + +// CSS-px dimensions reported by the capture script. The PNGs are 2× these numbers; +// we scale by width, so only the ratio matters. Order is the order they are stacked. +const SHOTS = [ + {src: `${DIR}/01-header.png`, w: 844, h: 758}, + {src: `${DIR}/02-details.png`, w: 844, h: 3102}, + {src: `${DIR}/06-bio.png`, w: 844, h: 3424}, +] as const + +const GAP = 36 // page gutter between cards, in source px + +const BIO_INDEX = 2 // the card the second half of the clip reads through + +// A hair of the card's top edge left showing, so the bio reads as part of a page +// rather than as a full-bleed slab of text. +const BIO_HEADROOM = 40 + +// ─── Motion ───────────────────────────────────────────────────────────────── +// Timing is expressed as scroll *speed*, not as a frame budget, so the clip stays +// legible whatever the profile's length: a longer bio makes a longer video rather +// than a faster, unreadable one. Duration falls out of the geometry below. +// +// The bio is the point of the clip, so it moves slowest. The details run a little +// quicker — skimmable rather than readable, which is what that card is. +const HOLD_FRAMES = 30 // 1.0s beat on his photo and name before anything moves +const DETAILS_SPEED = 28 // px per frame +const BIO_SPEED = 18.5 // px per frame + +// ─── Geometry ─────────────────────────────────────────────────────────────── +// Derived at module scope because the composition's duration depends on it, and +// that has to be known before the component renders. Canvas width is the story +// format's, which is the only format this scene is registered for. +const SCALE = FORMATS.story.width / SHOTS[0].w +const TOPS: number[] = [] +let cursor = 0 +for (const shot of SHOTS) { + TOPS.push(cursor) + cursor += shot.h + GAP +} +const PAGE_HEIGHT = (cursor - GAP) * SCALE + +// Where the details pass stops: the top of the bio card, just below the frame edge. +const BIO_Y = -(TOPS[BIO_INDEX] * SCALE - BIO_HEADROOM) +// Where it finishes: the bottom of the bio card resting on the bottom of the frame, +// which on this page is also the end of the scroll. +const END_Y = -(PAGE_HEIGHT - FORMATS.story.height) + +const DETAILS_END = HOLD_FRAMES + Math.round(Math.abs(BIO_Y) / DETAILS_SPEED) +export const PROFILE_SCROLL_DURATION = + DETAILS_END + Math.round(Math.abs(END_Y - BIO_Y) / BIO_SPEED) + +export const ProfileScroll: React.FC = () => { + const frame = useCurrentFrame() + const {width} = useVideoConfig() + + const y = interpolate( + frame, + [0, HOLD_FRAMES, DETAILS_END, PROFILE_SCROLL_DURATION], + [0, 0, BIO_Y, END_Y], + { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + easing: (t) => t, // per-segment linear; the segments themselves are the rhythm + }, + ) + + return ( + +
+ {SHOTS.map((shot, i) => ( + + ))} +
+ + +
+ ) +} + +// Always on screen. The scrim is what guarantees it stays legible whatever the page +// is showing underneath — the profile scrolls past both cream cards and warm gutters. +const UrlBar: React.FC<{url: string}> = ({url}) => { + const {height} = useVideoConfig() + return ( + <> + +
+
+ + + {url} + +
+
+ + ) +} diff --git a/tests/e2e/web/specs/signIn.spec.ts b/tests/e2e/web/specs/signIn.spec.ts index 461c30ea..6224cf83 100644 --- a/tests/e2e/web/specs/signIn.spec.ts +++ b/tests/e2e/web/specs/signIn.spec.ts @@ -44,6 +44,7 @@ test.describe('when given valid input', () => { Number(filteredProfiles?.split(' ')[0]), ) + // The seeking line only renders on the Large card, which this test selects above. const profile = await app.people.getProfileInfo() if (!profile) throw new Error('No profile found') const seeking = await getCardSeekingInfo(profile) diff --git a/web/components/buttons/file-upload-button.tsx b/web/components/buttons/file-upload-button.tsx index c115e07d..002420c7 100644 --- a/web/components/buttons/file-upload-button.tsx +++ b/web/components/buttons/file-upload-button.tsx @@ -22,7 +22,7 @@ export function FileUploadButton(props: { ) => { diff --git a/web/components/editor/image.tsx b/web/components/editor/image.tsx index 3f8486cc..e8e28bc1 100644 --- a/web/components/editor/image.tsx +++ b/web/components/editor/image.tsx @@ -1,6 +1,7 @@ import {Image} from '@tiptap/extension-image' import clsx from 'clsx' import {useState} from 'react' +import {MediaModal} from 'web/components/media-modal' export const BasicImage = Image.extend({ renderReact: (attrs: any) => {attrs.alt, @@ -31,14 +32,7 @@ function ExpandingImage(props: {src: string; alt?: string; title?: string; size? )} height={size === 'md' ? 400 : 128} /> - {expanded && ( -
setExpanded(false)} - > - {alt -
- )} + ) } diff --git a/web/components/editor/sticky-format-menu.tsx b/web/components/editor/sticky-format-menu.tsx index 107e5d39..63faa9c6 100644 --- a/web/components/editor/sticky-format-menu.tsx +++ b/web/components/editor/sticky-format-menu.tsx @@ -60,7 +60,7 @@ function UploadButton(props: {upload: UploadMutation}) { return ( diff --git a/web/components/editor/upload-extension.tsx b/web/components/editor/upload-extension.tsx index 3ad4aa5d..e732a0f5 100644 --- a/web/components/editor/upload-extension.tsx +++ b/web/components/editor/upload-extension.tsx @@ -13,13 +13,18 @@ export const useUploadMutation = (editor: Editor | null) => useMutation( (files: File[]) => // TODO: Images should be uploaded under a particular username - Promise.all(files.map((file) => uploadImage('default', file))), + Promise.all( + files.map(async (file) => ({ + src: await uploadImage('default', file), + isVideo: file.type.startsWith('video'), + })), + ), { - onSuccess(urls) { - if (!editor || !urls.length) return + onSuccess(uploads) { + if (!editor || !uploads.length) return let trans = editor.chain().focus() - urls.forEach((src) => { - trans = trans.setImage({src}) + uploads.forEach(({src, isVideo}) => { + trans = isVideo ? trans.setVideo({src}) : trans.setImage({src}) trans = trans.createParagraphNear() }) trans.run() diff --git a/web/components/editor/video.tsx b/web/components/editor/video.tsx new file mode 100644 index 00000000..c2a9601c --- /dev/null +++ b/web/components/editor/video.tsx @@ -0,0 +1,38 @@ +import clsx from 'clsx' +import Video from 'common/util/tiptap-video' +import {useState} from 'react' +import {MediaModal} from 'web/components/media-modal' + +export const BasicVideo = Video.extend({ + renderReact: (attrs: any) =>