Updates perfect-freehand, draw renderings

This commit is contained in:
Steve Ruiz 2021-09-17 17:37:40 +01:00
parent 39afd9a3f6
commit cdb7c74f8e
12 changed files with 221 additions and 117 deletions

View file

@ -832,6 +832,7 @@ export class Utils {
* @param tolerance The minimum line distance (also called epsilon).
* @returns Simplified array as [x, y, ...][]
*/
static simplify(points: number[][], tolerance = 1): number[][] {
const len = points.length
const a = points[0]

View file

@ -68,7 +68,7 @@
"@tldraw/core": "^0.0.94",
"@tldraw/intersect": "^0.0.94",
"@tldraw/vec": "^0.0.94",
"perfect-freehand": "^0.5.3",
"perfect-freehand": "^1.0.4",
"react-hotkeys-hook": "^3.4.0",
"rko": "^0.5.25"
},

View file

@ -21,8 +21,7 @@ import {
intersectRayBounds,
intersectRayEllipse,
} from '@tldraw/intersect'
const simplePathCache = new WeakMap<ArrowShape['handles'], string>()
import { EASINGS } from '~state/utils'
export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Arrow,
@ -95,11 +94,15 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
let startArrowHead: { left: number[]; right: number[] } | undefined
let endArrowHead: { left: number[]; right: number[] } | undefined
const getRandom = Utils.rng(shape.id)
const easing = EASINGS[getRandom() > 0 ? 'easeInOutSine' : 'easeInOutCubic']
if (isStraightLine) {
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
const sw = strokeWidth * (isDraw ? 1.25 : 2)
const path = isDraw
? renderFreehandArrowShaft(shape)
? renderFreehandArrowShaft(shape, arrowDist, easing)
: 'M' + Vec.round(start.point) + 'L' + Vec.round(end.point)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
@ -147,14 +150,14 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
} else {
const circle = getCtp(shape)
const sw = strokeWidth * (isDraw ? 1.25 : 1.618)
const path = isDraw
? renderCurvedFreehandArrowShaft(shape, circle)
: getArrowArcPath(start, end, circle, shape.bend)
const sw = strokeWidth * (isDraw ? 1 : 2)
const { center, radius, length } = getArrowArc(shape)
const path = isDraw
? renderCurvedFreehandArrowShaft(shape, circle, length, easing)
: getArrowArcPath(start, end, circle, shape.bend)
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
length - 1,
sw,
@ -211,9 +214,7 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
)
}
const sw = strokeWidth * 1.618
const dots = getArcPoints(shape)
const sw = strokeWidth * 2
return (
<SVGContainer ref={ref} {...events}>
@ -257,7 +258,11 @@ export const Arrow = new ShapeUtil<ArrowShape, SVGSVGElement, TLDrawMeta>(() =>
},
shouldRender(prev, next) {
return next.handles !== prev.handles || next.style !== prev.style
return (
next.decorations !== prev.decorations ||
next.handles !== prev.handles ||
next.style !== prev.style
)
},
getBounds(shape) {
@ -603,23 +608,35 @@ function getBendPoint(handles: ArrowShape['handles'], bend: number) {
return point
}
function renderFreehandArrowShaft(shape: ArrowShape) {
function renderFreehandArrowShaft(
shape: ArrowShape,
length: number,
easing: (t: number) => number
) {
const { style, id } = shape
const { start, end } = shape.handles
const getRandom = Utils.rng(id)
const strokeWidth = +getShapeStyle(style).strokeWidth * 2
const strokeWidth = getShapeStyle(style).strokeWidth
const count = 4 + Math.floor((Math.abs(length) / 40) * (1 + getRandom() / 2))
const stroke = getStroke(
[...Vec.pointsBetween(start.point, end.point), end.point, end.point, end.point],
[...Vec.pointsBetween(start.point, end.point, count, easing), end.point, end.point, end.point],
{
size: strokeWidth / 2,
thinning: 0.5 + getRandom() * 0.3,
easing: (t) => t * t,
end: shape.decorations?.end ? { cap: true } : { taper: strokeWidth * 20 },
start: shape.decorations?.start ? { cap: true } : { taper: strokeWidth * 20 },
size: strokeWidth * 2,
thinning: 0.618 + getRandom() * 0.2,
start: shape.decorations?.start
? { taper: length / 2 + 0.25 * Math.abs(getRandom()) }
: { cap: true },
end: shape.decorations?.end
? { taper: length / 2 + 0.25 * Math.abs(getRandom()) }
: { cap: true },
easing: EASINGS.easeOutQuad,
simulatePressure: true,
smoothing: 0,
streamline: 0,
last: true,
}
)
@ -629,13 +646,18 @@ function renderFreehandArrowShaft(shape: ArrowShape) {
return path
}
function renderCurvedFreehandArrowShaft(shape: ArrowShape, circle: number[]) {
function renderCurvedFreehandArrowShaft(
shape: ArrowShape,
circle: number[],
length: number,
easing: (t: number) => number
) {
const { style, id } = shape
const { start, end } = shape.handles
const getRandom = Utils.rng(id)
const strokeWidth = +getShapeStyle(style).strokeWidth * 2
const strokeWidth = getShapeStyle(style).strokeWidth
const center = [circle[0], circle[1]]
const radius = circle[2]
@ -646,20 +668,25 @@ function renderCurvedFreehandArrowShaft(shape: ArrowShape, circle: number[]) {
const points: number[][] = []
for (let i = 0; i < 21; i++) {
const t = i / 20
const count = 8 + Math.floor((Math.abs(length) / 40) * 1 + getRandom() / 2)
for (let i = 0; i < count + 1; i++) {
const t = easing(i / count)
const angle = Utils.lerpAngles(startAngle, endAngle, t)
points.push(Vec.round(Vec.nudgeAtAngle(center, angle, radius)))
}
const stroke = getStroke([...points, end.point, end.point, end.point], {
size: strokeWidth / 2,
thinning: 0.5 + getRandom() * 0.3,
easing: (t) => t * t,
end: shape.decorations?.end ? { cap: true } : { taper: strokeWidth * 20 },
start: shape.decorations?.start ? { cap: true } : { taper: strokeWidth * 20 },
size: strokeWidth * 2,
thinning: 0.618 + getRandom() * 0.2,
start: shape.decorations?.start
? { taper: length * (0.5 * Math.abs(getRandom())) }
: { cap: true },
end: shape.decorations?.end ? { taper: length * (0.5 * Math.abs(getRandom())) } : { cap: true },
easing: EASINGS.easeOutQuad,
simulatePressure: true,
streamline: 0.01,
streamline: 0,
smoothing: 0,
last: true,
})

View file

@ -295,19 +295,18 @@ function getFillPath(shape: DrawShape) {
function getDrawStrokePath(shape: DrawShape, isEditing: boolean) {
const styles = getShapeStyle(shape.style)
if (shape.points.length < 2) {
return ''
}
if (shape.points.length < 2) return ''
const options = shape.points[1][2] === 0.5 ? simulatePressureSettings : realPressureSettings
const stroke = getStroke(shape.points.slice(2), {
size: 1 + styles.strokeWidth * 2,
thinning: 0.8,
thinning: 0.7,
streamline: 0.7,
smoothing: 0.6,
smoothing: 0.5,
end: { taper: +styles.strokeWidth * 50 },
start: { taper: +styles.strokeWidth * 50 },
easing: (t) => Math.sin((t * Math.PI) / 2),
...options,
last: !isEditing,
})

View file

@ -9,6 +9,7 @@ import {
intersectLineSegmentEllipse,
intersectRayEllipse,
} from '@tldraw/intersect'
import { EASINGS } from '~state/utils'
export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(() => ({
type: TLDrawShapeType.Ellipse,
@ -44,7 +45,7 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
const ry = Math.max(0, radiusY - strokeWidth / 2)
if (style.dash === DashStyle.Draw) {
const path = renderPath(shape, this.getCenter(shape))
const path = getEllipsePath(shape, this.getCenter(shape))
return (
<SVGContainer ref={ref} {...events}>
@ -302,7 +303,7 @@ export const Ellipse = new ShapeUtil<EllipseShape, SVGSVGElement, TLDrawMeta>(()
/* Helpers */
/* -------------------------------------------------- */
function renderPath(shape: EllipseShape, boundsCenter: number[]) {
function getEllipsePath(shape: EllipseShape, boundsCenter: number[]) {
const {
style,
id,
@ -316,42 +317,28 @@ function renderPath(shape: EllipseShape, boundsCenter: number[]) {
const strokeWidth = +getShapeStyle(style).strokeWidth
const rx = radiusX + getRandom() * strokeWidth - strokeWidth / 2
const ry = radiusY + getRandom() * strokeWidth - strokeWidth / 2
const rx = radiusX + getRandom() * strokeWidth * 2
const ry = radiusY + getRandom() * strokeWidth * 2
const points: number[][] = []
const start = Math.PI + Math.PI * getRandom()
const extra = Math.abs(getRandom())
const overlap = Math.PI / 12
for (let i = 2; i < 8; i++) {
const rads = start + overlap * 2 * (i / 8)
for (let i = 0; i < 32; i++) {
const t = EASINGS.easeInOutSine(i / 32)
const rads = start * 2 + Math.PI * (2 + extra) * t
const x = rx * Math.cos(rads) + center[0]
const y = ry * Math.sin(rads) + center[1]
points.push([x, y])
}
for (let i = 5; i < 32; i++) {
const t = i / 35
const rads = start + overlap * 2 + Math.PI * 2.5 * (t * t * t)
const x = rx * Math.cos(rads) + center[0]
const y = ry * Math.sin(rads) + center[1]
points.push([x, y])
}
for (let i = 0; i < 8; i++) {
const rads = start + overlap * 2 * (i / 4)
const x = rx * Math.cos(rads) + center[0]
const y = ry * Math.sin(rads) + center[1]
points.push([x, y])
points.push([x, y, t + 0.5 + getRandom() / 2])
}
const stroke = getStroke(points, {
size: 1 + strokeWidth,
thinning: 0.6,
easing: (t) => t * t * t * t,
easing: EASINGS.easeInOutSine,
end: { taper: strokeWidth * 20 },
start: { taper: strokeWidth * 20 },
streamline: 0,
simulatePressure: false,
})

View file

@ -5,6 +5,7 @@ import getStroke from 'perfect-freehand'
import { getPerfectDashProps, defaultStyle, getShapeStyle } from '~shape/shape-styles'
import { RectangleShape, DashStyle, TLDrawShapeType, TLDrawToolType, TLDrawMeta } from '~types'
import { getBoundsRectangle, transformRectangle, transformSingleRectangle } from '../shared'
import { EASINGS } from '~state/utils'
const pathCache = new WeakMap<number[], string>([])
@ -39,7 +40,7 @@ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta
this
if (style.dash === DashStyle.Draw) {
const pathData = Utils.getFromCache(pathCache, shape.size, () => renderPath(shape))
const pathData = Utils.getFromCache(pathCache, shape.size, () => getRectanglePath(shape))
return (
<SVGContainer ref={ref} {...events}>
@ -170,7 +171,7 @@ export const Rectangle = new ShapeUtil<RectangleShape, SVGSVGElement, TLDrawMeta
/* Helpers */
/* -------------------------------------------------- */
function renderPath(shape: RectangleShape) {
function getRectanglePath(shape: RectangleShape) {
const styles = getShapeStyle(shape.style)
const getRandom = Utils.rng(shape.id)
@ -194,25 +195,32 @@ function renderPath(shape: RectangleShape) {
const br = Vec.add([w, h], offsets[2])
const bl = Vec.add([sw / 2, h], offsets[3])
const px = Math.max(8, Math.floor(w / 20))
const py = Math.max(8, Math.floor(h / 20))
const rm = Math.floor(5 + getRandom() * 4)
const lines = Utils.shuffleArr(
[
Vec.pointsBetween(tr, br),
Vec.pointsBetween(br, bl),
Vec.pointsBetween(bl, tl),
Vec.pointsBetween(tl, tr),
Vec.pointsBetween(tr, br, py, EASINGS.easeInOutCubic),
Vec.pointsBetween(br, bl, px, EASINGS.easeInOutCubic),
Vec.pointsBetween(bl, tl, py, EASINGS.easeInOutCubic),
Vec.pointsBetween(tl, tr, px, EASINGS.easeInOutCubic),
],
Math.floor(5 + getRandom() * 4)
rm
)
const stroke = getStroke([...lines.flat().slice(4), ...lines[0], ...lines[0].slice(4)], {
const stroke = getStroke(
[...lines.flat(), ...lines[0], ...lines[1]].slice(4, Math.floor((rm % 2 === 0 ? px : py) / -2)),
{
size: 1 + styles.strokeWidth,
thinning: 0.618,
easing: (t) => t * t * t * t,
thinning: 0.6,
easing: EASINGS.easeOutSine,
end: { cap: true },
start: { cap: true },
simulatePressure: false,
last: true,
})
}
)
return Utils.getSvgPathFromStroke(stroke)
}

View file

@ -142,6 +142,14 @@ export class TLDrawState extends StateManager<Data> {
/* -------------------- Internal -------------------- */
onReady = () => {
this.patchState({
appState: {
status: {
current: TLDrawStatus.Idle,
previous: TLDrawStatus.Idle,
},
},
})
this._onMount?.(this)
}

View file

@ -0,0 +1,52 @@
import type { Easing } from '~types'
export const EASINGS: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => --t * t * t + 1,
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
easeInQuart: (t) => t * t * t * t,
easeOutQuart: (t) => 1 - --t * t * t * t,
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t),
easeInQuint: (t) => t * t * t * t * t,
easeOutQuint: (t) => 1 + --t * t * t * t * t,
easeInOutQuint: (t) => (t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t),
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
easeInExpo: (t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10)),
easeOutExpo: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: (t) =>
t <= 0
? 0
: t >= 1
? 1
: t < 0.5
? Math.pow(2, 20 * t - 10) / 2
: (2 - Math.pow(2, -20 * t + 10)) / 2,
}
export const EASING_STRINGS: Record<Easing, string> = {
linear: `(t) => t`,
easeInQuad: `(t) => t * t`,
easeOutQuad: `(t) => t * (2 - t)`,
easeInOutQuad: `(t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)`,
easeInCubic: `(t) => t * t * t`,
easeOutCubic: `(t) => --t * t * t + 1`,
easeInOutCubic: `(t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1`,
easeInQuart: `(t) => t * t * t * t`,
easeOutQuart: `(t) => 1 - --t * t * t * t`,
easeInOutQuart: `(t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t`,
easeInQuint: `(t) => t * t * t * t * t`,
easeOutQuint: `(t) => 1 + --t * t * t * t * t`,
easeInOutQuint: `(t) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t`,
easeInSine: `(t) => 1 - Math.cos((t * Math.PI) / 2)`,
easeOutSine: `(t) => Math.sin((t * Math.PI) / 2)`,
easeInOutSine: `(t) => -(Math.cos(Math.PI * t) - 1) / 2`,
easeInExpo: `(t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10))`,
easeOutExpo: `(t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t))`,
easeInOutExpo: `(t) => t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2`,
}

View file

@ -307,3 +307,24 @@ export type ShapesWithProp<U> = MembersWithRequiredKey<
MappedByType<TLDrawShapeType, TLDrawShape>,
U
>
export type Easing =
| 'linear'
| 'easeInQuad'
| 'easeOutQuad'
| 'easeInOutQuad'
| 'easeInCubic'
| 'easeOutCubic'
| 'easeInOutCubic'
| 'easeInQuart'
| 'easeOutQuart'
| 'easeInOutQuart'
| 'easeInQuint'
| 'easeOutQuint'
| 'easeInOutQuint'
| 'easeInSine'
| 'easeOutSine'
| 'easeInOutSine'
| 'easeInExpo'
| 'easeOutExpo'
| 'easeInOutExpo'

View file

@ -88,6 +88,11 @@ export class Vec {
return [A[0] * n, A[1] * n]
}
/**
* Multiple two vectors.
* @param A
* @param B
*/
static mulV = (A: number[], B: number[]): number[] => {
return [A[0] * B[0], A[1] * B[1]]
}
@ -300,7 +305,7 @@ export class Vec {
* @param t scalar
*/
static lrp = (A: number[], B: number[], t: number): number[] => {
return Vec.add(A, Vec.mul(Vec.vec(A, B), t))
return Vec.add(A, Vec.mul(Vec.sub(B, A), t))
}
/**
@ -501,18 +506,21 @@ export class Vec {
}
/**
* Get a number of points between two points.
* @param a
* @param b
* @param steps
* Get an array of points (with simulated pressure) between two points.
* @param A The first point.
* @param B The second point.
* @param steps The number of points to return.
* @param ease An easing function to apply to the simulated pressure.
*/
static pointsBetween = (a: number[], b: number[], steps = 6): number[][] => {
return Array.from(Array(steps))
.map((_, i) => {
const t = i / steps
return t * t * t * t
})
.map((t) => Vec.round([...Vec.lrp(a, b, t), (1 - t) / 2]))
static pointsBetween = (
A: number[],
B: number[],
steps = 6,
ease = (t: number) => t * t * t * t
): number[][] => {
return Array.from(Array(steps)).map((_, i) =>
Vec.round([...Vec.lrp(A, B, i / steps), (1 - ease(i / steps)) / 2])
)
}
}

View file

@ -0,0 +1,15 @@
import { signOut } from 'next-auth/client'
import Head from 'next/head'
export default function SignOut(): JSX.Element {
return (
<>
<Head>
<title>tldraw</title>
</Head>
<div>
<button onClick={() => signOut()}>Sign Out</button>
</div>
</>
)
}

View file

@ -3626,15 +3626,6 @@
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0"
"@tldraw/core@^0.0.53":
version "0.0.53"
resolved "https://registry.yarnpkg.com/@tldraw/core/-/core-0.0.53.tgz#2db2b27df441169e452e0aa07570adca8b06b582"
integrity sha512-hxZIUR3Sm320tvGW5lWEKfw1QJhe6mJu7IrG5ka5G3slusqaY3cQY9EafFqH07yEXul2MU2RENIQus7fh+Gwcg==
dependencies:
deepmerge "^4.2.2"
ismobilejs "^1.1.1"
react-use-gesture "^9.1.3"
"@tootallnate/once@1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@ -8144,11 +8135,6 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
ismobilejs@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ismobilejs/-/ismobilejs-1.1.1.tgz#c56ca0ae8e52b24ca0f22ba5ef3215a2ddbbaa0e"
integrity sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
@ -10744,13 +10730,10 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
perfect-freehand@^0.5.3:
version "0.5.3"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-0.5.3.tgz#41641d17aceb795db445ae84573396e3cce8878f"
integrity sha512-WbMEf78Chx1APwGNoKQf64fbUa12fCAXziKUf2BWoeZ2upsKu5OirCzLnIgjeZYkIB6jOoOtQawCb7CZZ+t/Aw==
dependencies:
"@tldraw/core" "^0.0.53"
rko "^0.5.19"
perfect-freehand@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.0.4.tgz#1c5318164b10a74a4e6664b8519ff68799b8ab52"
integrity sha512-feJV7C2LSiz6czFZuexYzxh8GHzaQ32bU4Vx+Y4xdCZYxnPFFyCoMVbVL3797zmk0v5rzGJkVfMQRUkLb/uZIg==
performance-now@^2.1.0:
version "2.1.0"
@ -11213,11 +11196,6 @@ react-style-singleton@^2.1.0:
invariant "^2.2.4"
tslib "^1.0.0"
react-use-gesture@^9.1.3:
version "9.1.3"
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-9.1.3.tgz#92bd143e4f58e69bd424514a5bfccba2a1d62ec0"
integrity sha512-CdqA2SmS/fj3kkS2W8ZU8wjTbVBAIwDWaRprX7OKaj7HlGwBasGEFggmk5qNklknqk9zK/h8D355bEJFTpqEMg==
react@17.0.2, react@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"
@ -11681,7 +11659,7 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
rko@^0.5.19, rko@^0.5.25:
rko@^0.5.25:
version "0.5.25"
resolved "https://registry.yarnpkg.com/rko/-/rko-0.5.25.tgz#1095803900e3f912f6adf8a1c113b8227d3d88bf"
integrity sha512-HU6M3PxK3VEqrr6QZKAsqO98juQX24kEgJkKSdFJhw8U/DBUGAnU/fgyxNIaTw7TCI7vjIy/RzBEXf5I4sijKg==